Compare commits

...

42 Commits

Author SHA1 Message Date
elpatron f23f0db70b chore: release v0.1.0.31 2026-05-30 11:46:26 +02:00
elpatron ece0abccbf docs: README um Web-Push-Dokumentation ergänzen
Beschreibt Opt-in, VAPID, iOS-PWA, Projektstruktur und Deployment-Hinweise.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:46:19 +02:00
elpatron 92e9020212 feat: Unterstützung für benutzerdefinierte Service Worker und Opt-in für Web Push-Benachrichtigungen
Ermöglicht es Logbuch-Eignern, benutzerdefinierte Service Worker zu verwenden und Web Push-Benachrichtigungen für Änderungen von Collaborators zu aktivieren, mit einem Opt-in in den Einstellungen.
2026-05-30 11:40:57 +02:00
elpatron 2428313a22 feat: Web Push für Logbuch-Eigner bei Crew-Sync
Benachrichtigt Owner optional per VAPID/Web Push, wenn Collaborators
Änderungen synchronisieren — ohne Klartext-Inhalte, mit Opt-in in den
Einstellungen, Custom Service Worker und Deep-Link zum Logbuch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:36:03 +02:00
elpatron 0e61bc5dad chore: release v0.1.0.30 2026-05-30 11:14:21 +02:00
elpatron 585ef788df fix: Rückgabetyp von fingerprintSignature für Passkey-Signaturen korrigieren
Behebt den TypeScript-Buildfehler, da Passkey-Signaturen Objekte und keine Strings sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:14:15 +02:00
elpatron 9aabb2729d chore: release v0.1.0.29 2026-05-30 11:12:37 +02:00
elpatron ebe4199b8b fix: Footer-Copyright und Signatur-Fingerprint vereinheitlichen
Footer zeigt KnorrLabs/Markus F.J. Busche mit Mailto nur am Namen. Signatur-Normalisierung ist für Persistenz und isDirty-Check konsistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:12:26 +02:00
elpatron 10f01f1ffc chore: release v0.1.0.28 2026-05-30 11:08:18 +02:00
elpatron 29765d172e feat: Ereignisse sofort speichern und Save-Button bei Änderungen aktivieren
Ereignisprotokoll-Einträge werden direkt persistiert, ohne vorher die Logbuchseite zu speichern. Der Speichern-Button ist nur aktiv, wenn noch ungespeicherte Änderungen vorliegen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:07:54 +02:00
elpatron 5f9e83dbdd chore: release v0.1.0.27 2026-05-30 10:56:53 +02:00
elpatron aa2b35ddac feat: Ereignisprotokoll bearbeiten und Skipper-Signatur invalidieren
Bestehende Ereigniszeilen lassen sich nachträglich ändern; beim Speichern
oder Löschen wird nur die Skipper-Unterschrift entfernt, die Crew-Signatur bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:56:45 +02:00
elpatron b5bc80594c chore: release v0.1.0.26 2026-05-30 10:41:35 +02:00
elpatron b88ce17e1d fix: Prevent signature alert loop when adding log events.
Stabilize dialog callbacks and dedupe signature-invalidation alerts so the UI no longer freezes after adding an event to a signed travel day.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:40:31 +02:00
elpatron 3849b5a2f0 chore: release v0.1.0.25 2026-05-30 10:18:24 +02:00
elpatron 1225601d7a fix: Demo navigation via history API and route sync.
Replace unreliable pathname assignment with pushState and central route syncing so the demo opens from the login screen and responds to browser back/forward.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:18:14 +02:00
elpatron 180e5727df chore: release v0.1.0.24 2026-05-30 10:16:50 +02:00
elpatron 94b13c8d60 fix: Add fileType to PublicDemoFixture gpsTracks type for CI build.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:16:32 +02:00
elpatron 69dddf7838 chore: release v0.1.0.23 2026-05-30 10:15:20 +02:00
elpatron 53eee9a3ad Add public read-only demo at /demo without account.
Let visitors explore ship data, crew, and sample log entries from the login page, with onboarding tour support and a fix for GPS track rendering when fileType is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:11:53 +02:00
elpatron ebe21c5a6f chore: release v0.1.0.22 2026-05-30 09:47:52 +02:00
elpatron 61f04902cb fix: Screenreader-Label für gültige Skipper-Signatur-Badge
Versteckter „Skipper“-Text ergänzt, damit die nur-Icon-Badge barrierefrei bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 09:47:47 +02:00
elpatron 166eeaf000 chore: release v0.1.0.21 2026-05-30 09:45:28 +02:00
elpatron c1418b5981 feat: Kapitänsmütze statt Text in Skipper-Signatur-Badge
Eigenes CaptainCap-Icon im Lucide-Stil; Tooltip und aria-label bleiben für Barrierefreiheit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 09:45:20 +02:00
elpatron 181459c7e8 chore: release v0.1.0.20 2026-05-30 09:17:32 +02:00
elpatron ebeb05e865 feat: Skipper-Signatur-Badge auf Reisetag-Kacheln
Zeigt in der Journal-Liste an, ob ein Eintrag vom Skipper freigegeben ist
und ob eine Passkey-Signatur nach Inhaltsänderung ungültig geworden ist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 09:14:17 +02:00
elpatron 64c0d8cd47 docs: Update copyright information in README to reflect new ownership 2026-05-29 21:46:59 +02:00
elpatron e2e65e80ef chore: release v0.1.0.19 2026-05-29 21:17:58 +02:00
elpatron 4d3ba58971 refactor: Improve Git synchronization process in update-prod.sh
Enhanced the update-prod.sh script to better handle Git operations. The script now checks for local changes before syncing, fetches tags from the origin, and performs a hard reset to the current branch, providing clearer error messages for potential failures.
2026-05-29 21:17:42 +02:00
elpatron c5090aa59e chore: release v0.1.0.18 2026-05-29 21:13:59 +02:00
elpatron fa8a381739 feat: Clean up orphaned sync queue items after logbook synchronization
Added functionality to remove orphaned queue items for logbooks that are no longer present in the database after synchronizing all logbooks. This ensures the sync queue remains accurate and up-to-date.
2026-05-29 21:13:48 +02:00
elpatron aeb304baf6 docs: Update README formatting for better readability
Adjusted the layout of the architecture diagram in the README to enhance clarity and presentation.
2026-05-29 20:47:13 +02:00
elpatron ea3985f425 docs: README um Statistik, Rollen und Backup/Restore ergänzen
Funktionsliste, Zugriffsmatrix und Kurzanleitung für .daagbok.json-Backups nachziehen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:47:01 +02:00
elpatron 4b8e04262d chore: release v0.1.0.17 2026-05-29 20:44:34 +02:00
elpatron e24148923f feat: Backup-Hinweis im Logbuch-Löschdialog
Vor dem unwiderruflichen Löschen wird auf Einstellungen → Backup & Wiederherstellung verwiesen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:44:11 +02:00
elpatron b317be5ae1 feat: Logbuch-Backup/Restore für Eigner mit Plausible-Events
Vollständiges verschlüsseltes .daagbok.json-Backup inkl. Fotos und GPS; Restore auf gleichem oder neuem Account. Events Backup Exported und Backup Restored mit Anzahlen und Restore-Modus.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:42:44 +02:00
elpatron 481724bcb6 fix: Collaboration-Rolle explizit validieren statt still auf WRITE fallen
parseCollaborationRole warnt bei fehlendem oder ungültigem role-Feld und wird bei Einladung sowie Logbuch-Sync genutzt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:36:17 +02:00
elpatron 96ebb8357d feat: Eigene und geteilte Logbücher in der UI klar unterscheiden
Rollen-Badges, getrennte Dashboard-Bereiche und Header-Hinweise für Crew-Zugang; collaborationRole wird beim Sync und bei Einladungen gespeichert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:32:45 +02:00
elpatron 415a7a4e4e chore: release v0.1.0.16 2026-05-29 20:26:38 +02:00
elpatron cb4f1b5989 fix: Sync-Queue-Coalescing an lokalen DB-Zustand koppeln
Delete schlägt veraltete Upserts nur wenn die Entität lokal entfernt wurde; existiert sie noch, gewinnt die neueste Aktion (Recreate nach Delete).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:23:43 +02:00
elpatron b37f935e87 chore: release v0.1.0.15 2026-05-29 20:20:51 +02:00
elpatron 213001b139 fix: Sync-Queue-Coalescing nach chronologischer ID statt Delete-Priorität
Nach Löschen und erneutem Anlegen wurde der Create-Eintrag fälschlich verworfen, weil Deletes immer bevorzugt wurden — jetzt gewinnt die höchste Queue-ID.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:20:34 +02:00
49 changed files with 4210 additions and 430 deletions
+182
View File
@@ -0,0 +1,182 @@
---
name: merge
description: >-
Merge Git branches safely — fetch latest, merge or rebase onto master, resolve
conflicts intelligently, and verify the result. Use when the user asks to merge
branches, sync with master, resolve merge conflicts, or bring a feature branch
up to date.
---
# Git Merge
Führe Branch-Merges sicher und nachvollziehbar aus. Für PR-Review, CI und
Comment-Triage siehe den **babysit**-Skill — dieser Skill deckt die Git-Merge-
Operation selbst ab.
## Projekt-Kontext
- **Basis-Branch:** `master` (nicht `main`)
- **Monorepo:** `client/` (React PWA) und `server/` (Express API) — Konflikte
können in beiden liegen
## Sicherheitsregeln (immer einhalten)
- **Niemals** `git config` ändern
- **Niemals** `--no-verify`, `--no-gpg-sign` o.ä. ohne explizite Anfrage
- **Niemals** `push --force` auf `master` — bei Bedarf warnen und abbrechen
- **Niemals** destruktive Befehle (`reset --hard`, `clean -fd`) ohne explizite Anfrage
- **Niemals** interaktive Git-Befehle (`-i`-Flags) — nicht unterstützt
- **Kein Commit** ohne explizite Anfrage des Users
- **Kein Push** ohne explizite Anfrage des Users
## Workflow
### 1. Ausgangslage klären
Parallel ausführen:
```bash
git status
git branch -vv
git log --oneline -5
```
Ermittle:
- Aktueller Branch
- Ziel-Branch (Standard: `master`)
- Ob uncommittete Änderungen vorliegen
- Ob der Branch einen Remote-Tracking-Branch hat
**Bei uncommitteten Änderungen:** Stashen (`git stash push -m "pre-merge"`) nur
mit Zustimmung oder wenn der User es verlangt hat. Sonst stoppen und melden.
### 2. Merge-Strategie wählen
| Situation | Empfehlung |
|-----------|------------|
| Feature-Branch aktuell halten | `git merge origin/master` (Merge-Commit) |
| Linearer Verlauf gewünscht | `git rebase origin/master` (nur wenn User Rebase verlangt) |
| Zwei Feature-Branches zusammenführen | `git merge <branch>` auf Ziel-Branch |
**Standard:** Merge (nicht Rebase), es sei denn der User verlangt Rebase.
### 3. Remote aktualisieren
```bash
git fetch origin
```
Vor dem Merge prüfen, wie weit der Branch hinter `origin/master` liegt:
```bash
git log --oneline HEAD..origin/master
git log --oneline origin/master..HEAD
```
### 4. Merge ausführen
**Feature-Branch mit master synchronisieren** (häuigster Fall):
```bash
git checkout <feature-branch>
git merge origin/master
```
**Branch in master mergen** (nur wenn User das ausdrücklich will — normalerweise
passiert das via PR):
```bash
git checkout master
git pull origin master
git merge <feature-branch>
```
Merge-Commit-Nachricht kurz und sachlich halten, z.B.:
`Merge branch 'master' into feature/push-notifications-owner`
### 5. Konflikte lösen
Konfliktdateien finden:
```bash
git diff --name-only --diff-filter=U
```
**Pro Konfliktdatei:**
1. Datei lesen und beide Seiten verstehen (HEAD = eigener Branch, incoming = gemergter Branch)
2. Intent beider Änderungen erhalten — nicht blind eine Seite wählen
3. Konfliktmarker entfernen (`<<<<<<<`, `=======`, `>>>>>>>`)
4. Bei widersprüchlicher Intent: Merge abbrechen und User fragen
```bash
git merge --abort # oder: git rebase --abort
```
**Typische Konflikt-Muster in diesem Projekt:**
| Bereich | Hinweis |
|---------|---------|
| `package-lock.json` | Nach manueller Lösung `npm install` im betroffenen Paket (`client/` oder `server/`) ausführen |
| i18n (`client/src/i18n/`) | Beide Sprachkeys (DE + EN) behalten, keine Keys verlieren |
| Prisma/Schema | Migrationen beider Seiten zusammenführen, nicht überschreiben |
| Verschlüsselung/Auth | Vorsichtig — keine Sicherheitslogik stillschweigend vereinfachen |
Nach jeder gelösten Datei:
```bash
git add <file>
```
Merge abschließen (nur wenn User Commit verlangt hat):
```bash
git commit -m "$(cat <<'EOF'
Merge branch 'master' into <feature-branch>
EOF
)"
```
### 6. Verifizieren
Nach erfolgreichem Merge:
```bash
git status
git log --oneline -5
```
Relevante Checks je nach betroffenen Bereichen:
```bash
# Client
cd client && npm run build
# Server
cd server && npm run build
```
Bei Lockfile-Konflikten oder Dependency-Änderungen: Build in beiden Paketen prüfen.
### 7. Abschluss
- Ergebnis dem User mitteilen: welche Branches, wie viele Konflikte, was gelöst wurde
- Bei `git stash`: erinnern, Stash wieder anzuwenden (`git stash pop`)
- Push nur auf explizite Anfrage: `git push origin <branch>`
## Wann abbrechen und fragen
- Widersprüchliche fachliche Intent (z.B. beide Seiten ändern dieselbe Logik unterschiedlich)
- Konflikte in Krypto-, Auth- oder Sync-Kernlogik ohne klares „richtig“
- Merge würde `.env`, Credentials oder Secrets einschließen
- User wollte nur Status prüfen, nicht tatsächlich mergen
## Abgrenzung zu anderen Skills
| Skill | Wann |
|-------|------|
| **merge** (dieser) | Git merge/rebase, Konflikte, Branch sync |
| **babysit** | PR merge-ready: Comments, CI, PR-Konflikte im PR-Kontext |
| **creating-pull-requests** | PR erstellen und pushen |
+7 -1
View File
@@ -4,4 +4,10 @@ OpenWeatherMapAPIKey=<owm_api_key>
# For local dev: localhost and http://localhost
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
RP_ID=localhost
ORIGIN=http://localhost
ORIGIN=http://localhost
# 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
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
+63 -8
View File
@@ -13,15 +13,18 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
## Funktionen
- **Passkey-Authentifizierung** (WebAuthn) mit optionaler Recovery-Phrase und lokalem PIN-Fallback
- **Mehrere Logbücher** pro Benutzerkonto
- **Mehrere Logbücher** pro Benutzerkonto — eigene Logbücher und per Einladung geteilte Logbücher (Crew-Zugang) klar getrennt
- **Reisetage** mit Hafen, Wetter, Tankständen, Ereignissen und Tagesnummer
- **GPS-Tracks** (GPX/KML/GeoJSON-Upload, Karte, Statistiken)
- **Foto-Anhänge** pro Reisetag
- **Passkey-Signaturen** für Skipper und Crew (hybride elektronische Signatur)
- **Schiffsdaten** und **Crew-Profile** (Skipper + Mitglieder)
- **Kollaboration** — Crew per Einladungslink einladen
- **Statistik-Dashboard** — Strecken, Verbrauch, Segel/Motor, Hafenkette (pro Logbuch oder accountweit)
- **Kollaboration** — Crew per Einladungslink einladen (Schreib- oder Lesezugriff)
- **Push-Benachrichtigungen** (optional) — Logbuch-Eigner per Web Push informieren, wenn Crew Änderungen synchronisiert (ohne Klartext-Inhalte; Opt-in in den Einstellungen)
- **Read-only-Freigabe** — öffentlicher Lese-Link für Dritte
- **Export** — PDF pro Reisetag, CSV-Download/-Teilen
- **Backup & Wiederherstellung** — vollständiges verschlüsseltes Logbuch-Backup (Einträge, Fotos, GPS, Crew, Schiff) als `.daagbok.json`; Restore auf gleichem oder neuem Account
- **PWA** — installierbar auf iOS/Android, Offline-Modus, Update-Hinweise
- **Mehrsprachig** — Deutsch und Englisch
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer
@@ -29,7 +32,7 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
## Architektur
```
┌─────────────────┐ HTTPS/API ┌─────────────────┐
┌─────────────────┐ HTTPS/API ┌─────────────────┐
│ React PWA │ ◄──────────────────► │ Express API │
│ Vite + Dexie │ (nur ciphertext) │ Prisma + PG │
│ IndexedDB │ │ PostgreSQL │
@@ -44,6 +47,48 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
| Datenbank | PostgreSQL 16 |
| Auth | WebAuthn (Passkeys) via `@simplewebauthn` |
| Krypto | Web Crypto API (AES-GCM), BIP39 Recovery |
| Push (optional) | Web Push (VAPID), Custom Service Worker (`injectManifest`) |
### Rollen & Zugriff
| Rolle | Bedeutung |
|-------|-----------|
| **Owner** | Logbuch angelegt; voller Zugriff, Einladungen, Backup, Löschen; optional Push bei Crew-Änderungen |
| **Collaborator (WRITE)** | Per Einladung; Einträge bearbeiten und als Crew signieren |
| **Collaborator (READ)** | Nur Lesen (z. B. öffentlicher Share-Link) |
Skipper- und Crew-Profile im Logbuch sind **Inhaltsdaten** (verschlüsselt), nicht an den Account gebunden. Ein Account kann gleichzeitig Owner eines eigenen und Collaborator in fremden Logbüchern sein.
## Backup & Wiederherstellung
Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
1. Backup-Passphrase wählen (min. 8 Zeichen, getrennt von der Datei aufbewahren)
2. Download als `.daagbok.json` — enthält alle verschlüsselten Payloads inkl. **Fotos** und GPS-Tracks
3. **Wiederherstellen** in einem beliebigen Account (nach Registrierung/Login): Datei + Passphrase
Vor dem Löschen eines Logbuchs weist die App auf diese Funktion hin. Crew-Einladungen und Passkey-Signaturen werden nicht mitübertragen — Inhalte bleiben lesbar, Signaturen auf neuem Account ggf. nicht mehr verifizierbar.
## Push-Benachrichtigungen (optional)
Logbuch-**Eigner** können unter **Einstellungen** Web Push aktivieren. Sobald ein eingeladenes Crewmitglied mit Schreibrechten (`WRITE`) Änderungen synchronisiert, erhält der Owner eine **generische** Benachrichtigung — der Server kennt keine Logbuch-Inhalte (Zero-Knowledge).
| Aspekt | Verhalten |
|--------|-----------|
| Auslöser | Erfolgreicher Sync-Push durch Collaborator (`create`/`update`) |
| Aggregation | Mehrere Änderungen in einem Sync → eine Benachrichtigung pro Logbuch |
| Drosselung | Max. eine Push-Nachricht pro Logbuch alle 3 Minuten |
| Klick | Öffnet die App auf dem betroffenen Logbuch |
**Voraussetzungen:**
- HTTPS (Produktion)
- VAPID-Schlüssel auf dem Server (`VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`)
- Browser-Berechtigung „Benachrichtigungen“; auf **iOS** installierte PWA ab iOS 16.4+
Schlüssel erzeugen: `npx web-push generate-vapid-keys` (im `server/`-Verzeichnis oder global).
Ausführlicher Implementierungs- und Testplan: [docs/push-notifications-plan.md](docs/push-notifications-plan.md).
## Projektstruktur
@@ -52,13 +97,15 @@ kapteins-daagbok/
├── client/ # React-PWA (Frontend)
│ ├── src/
│ │ ├── components/ # UI-Komponenten
│ │ ├── services/ # Auth, Sync, Krypto, Analytics, …
│ │ ├── services/ # Auth, Sync, Krypto, Backup, Push, Analytics, …
│ │ ├── sw.ts # Service Worker (Precache + Web Push)
│ │ └── i18n/ # DE/EN-Übersetzungen
│ └── Dockerfile # Nginx-Produktions-Image
├── server/ # Express-API + Prisma
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign, push
│ ├── src/services/ # z. B. pushNotify (Web Push)
│ └── prisma/ # Datenbankschema
├── docs/ # Projektdokumentation (z. B. Plausible Events)
├── docs/ # Projektdokumentation
├── scripts/ # Dev- und Deploy-Skripte
├── docker-compose.yml # Produktions-Stack (DB + Backend + Frontend)
└── VERSION # App-Version (Build & Footer)
@@ -70,6 +117,7 @@ kapteins-daagbok/
- **npm**
- **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack)
- Optional: OpenWeatherMap-API-Key (Wetter-Abruf in den Einstellungen)
- Optional: VAPID-Schlüssel für Web Push (siehe Abschnitt Push-Benachrichtigungen)
## Lokale Entwicklung
@@ -94,6 +142,10 @@ Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen, z. B.:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public"
RP_ID=localhost
ORIGIN=http://localhost:5173
# Optional — Web Push (npx web-push generate-vapid-keys)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
```
### 3. Datenbank & Schema
@@ -126,7 +178,7 @@ Gesamten Stack lokal bauen und starten:
Frontend: http://localhost · API: http://localhost/api/health
Umgebungsvariablen für Passkeys in `.env` setzen (`RP_ID`, `ORIGIN`).
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID` und `ORIGIN` für Passkeys. Für Push die VAPID-Variablen an den **Backend**-Container durchreichen (z. B. in `docker-compose.yml` unter `backend.environment` ergänzen).
## Deployment
@@ -138,11 +190,14 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN` und bei Push `VAPID_*` enthalten. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
## Dokumentation
| Dokument | Inhalt |
|----------|--------|
| [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 |
| [.planning/PROJECT.md](.planning/PROJECT.md) | Produktvision und Anforderungen (GSD) |
## Analytics
@@ -155,4 +210,4 @@ Aktuelle Version: siehe [VERSION](VERSION) (wird im App-Footer und beim Docker-B
---
© 2026 Markus F.J. Busche · [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
© 2026 KnorrLabs/Markus F.J. Busche · [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
+1 -1
View File
@@ -1 +1 @@
0.1.0.15
0.1.0.32
+3 -1
View File
@@ -36,6 +36,9 @@
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vite-plugin-pwa": "^1.3.0"
},
"optionalDependencies": {
"@rolldown/binding-linux-x64-gnu": "^1.0.2"
}
},
"node_modules/@apideck/better-ajv-errors": {
@@ -2096,7 +2099,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
+3
View File
@@ -38,5 +38,8 @@
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vite-plugin-pwa": "^1.3.0"
},
"optionalDependencies": {
"@rolldown/binding-linux-x64-gnu": "^1.0.2"
}
}
+188 -1
View File
@@ -839,6 +839,42 @@ html.scheme-dark .themed-select-option.is-selected {
background: var(--app-surface-hover);
}
.logbook-card--shared {
border-left: 3px solid #38bdf8;
}
.logbook-sections {
display: flex;
flex-direction: column;
gap: 28px;
}
.logbook-section-header h3 {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
color: var(--app-text-heading);
}
.logbook-section-hint {
margin: 0 0 14px;
font-size: 13px;
line-height: 1.45;
color: var(--app-text-muted);
max-width: 52rem;
}
.card-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.card-title-row h3 {
margin: 0;
}
.card-icon {
background: var(--app-accent-bg);
color: var(--app-accent-light);
@@ -895,6 +931,44 @@ html.scheme-dark .themed-select-option.is-selected {
color: var(--app-text-subtle);
}
.entry-sign-badge {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
}
.entry-sign-badge--skipper.valid {
color: #86efac;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
padding: 3px 7px;
}
.entry-sign-badge--skipper.invalid {
color: #fde68a;
background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.28);
}
.entry-sign-badge__sr-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.btn-delete {
background: none;
border: none;
@@ -999,6 +1073,13 @@ html.scheme-dark .themed-select-option.is-selected {
color: var(--app-text-heading);
}
.app-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.app-title-area .app-subtitle {
font-size: 12px;
color: var(--app-text-muted);
@@ -1349,6 +1430,18 @@ html.scheme-dark .themed-select-option.is-selected {
vertical-align: middle;
}
.events-actions-td {
white-space: nowrap;
}
.events-actions-td .btn-icon {
margin-left: 4px;
}
.events-actions-td .btn-icon:first-child {
margin-left: 0;
}
.events-table tbody tr:hover {
background: rgba(255, 255, 255, 0.02);
}
@@ -2953,10 +3046,14 @@ html.theme-cupertino .events-scroll-container {
.app-version-footer__copyright {
color: #94a3b8;
}
.app-version-footer__copyright a {
color: inherit;
text-decoration: none;
}
.app-version-footer__copyright:hover {
.app-version-footer__copyright a:hover {
color: #e2e8f0;
text-decoration: underline;
}
@@ -2975,6 +3072,96 @@ html.theme-cupertino .events-scroll-container {
border: 1px solid rgba(251, 191, 36, 0.25);
}
.role-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
}
.role-badge--owner {
color: #86efac;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
}
.role-badge--crew {
color: #7dd3fc;
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.28);
}
.role-badge--read {
color: #cbd5e1;
background: rgba(148, 163, 184, 0.12);
border: 1px solid rgba(148, 163, 184, 0.25);
}
.backup-panel .backup-section {
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid var(--app-border-subtle);
}
.backup-panel .backup-section--import {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.backup-section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--app-text-heading);
}
.backup-section-desc {
font-size: 13px;
margin: 0 0 14px;
}
.backup-actions-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.backup-preview {
margin-top: 16px;
padding: 14px 16px;
border-radius: var(--app-radius-card);
border: 1px solid var(--app-border-subtle);
}
.backup-preview-title {
margin: 0 0 10px;
font-size: 16px;
font-weight: 600;
color: var(--app-text-heading);
}
.backup-preview-stats {
margin: 0 0 8px;
padding-left: 18px;
font-size: 13px;
color: var(--app-text-muted);
}
.backup-preview-date {
margin: 0;
font-size: 12px;
}
.app-tour-root {
position: fixed;
inset: 0;
+161 -10
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import './App.css'
import { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx'
@@ -23,10 +23,14 @@ import {
} from './services/appearance.js'
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
import DemoViewer from './components/DemoViewer.tsx'
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
import AppFooter from './components/AppFooter.tsx'
import LogbookRoleBadge from './components/LogbookRoleBadge.tsx'
import { db } from './services/db.js'
import { getLogbookAccess } from './services/logbookAccess.js'
import type { LogbookAccessRole } from './services/logbook.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
@@ -35,6 +39,10 @@ import {
getStoredDemoFirstEntryId,
seedDemoLogbookIfNeeded
} from './services/demoLogbook.js'
import { fetchLogbooks } from './services/logbook.js'
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
function App() {
const { t, i18n } = useTranslation()
@@ -54,11 +62,42 @@ function App() {
const [shareToken, setShareToken] = useState('')
const [shareKey, setShareKey] = useState('')
// Public demo mode (no account required)
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
const syncQueueCount = useLiveQuery(
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
[activeLogbookId]
)
const activeLogbookRecord = useLiveQuery(
() => (activeLogbookId ? db.logbooks.get(activeLogbookId) : undefined),
[activeLogbookId]
)
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole>('OWNER')
useEffect(() => {
if (!activeLogbookId) {
setActiveAccessRole('OWNER')
return
}
if (activeLogbookRecord?.isShared !== 1) {
setActiveAccessRole('OWNER')
return
}
const cachedRole = activeLogbookRecord.collaborationRole
if (cachedRole) {
setActiveAccessRole(cachedRole)
}
getLogbookAccess(activeLogbookId).then((access) => {
if (access) setActiveAccessRole(access.role)
})
}, [activeLogbookId, activeLogbookRecord])
useEffect(() => {
const syncAppearance = () => {
applyAppearanceToDocument(resolveAppTheme(), resolveColorScheme())
@@ -103,19 +142,47 @@ function App() {
}
}, [isAuthenticated])
useEffect(() => {
const syncRouteFromLocation = useCallback(() => {
const params = new URLSearchParams(window.location.search)
const hashParams = new URLSearchParams(window.location.hash.substring(1))
const path = window.location.pathname
if (window.location.pathname === '/share' && params.has('token') && hashParams.has('key')) {
setShareToken(params.get('token') || '')
setShareKey(hashParams.get('key') || '')
setIsViewerMode(true)
if (path === '/demo') {
setIsDemoMode(true)
setIsViewerMode(false)
setIsAcceptingInvite(false)
return
}
setIsDemoMode(false)
if (path === '/share' && params.has('token') && hashParams.has('key')) {
setShareToken(params.get('token') || '')
setShareKey(hashParams.get('key') || '')
setIsViewerMode(true)
setIsAcceptingInvite(false)
return
}
setIsViewerMode(false)
if (params.has('token')) {
setIsAcceptingInvite(true)
return
}
setIsAcceptingInvite(false)
const openLogbookId = params.get('logbook')
if (openLogbookId) {
sessionStorage.setItem(PENDING_PUSH_LOGBOOK_KEY, openLogbookId)
const cleanUrl = new URL(window.location.href)
cleanUrl.searchParams.delete('logbook')
window.history.replaceState(
{},
document.title,
`${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}`
)
}
const savedUser = localStorage.getItem('active_username')
@@ -131,6 +198,19 @@ function App() {
}
}, [])
useEffect(() => {
syncRouteFromLocation()
window.addEventListener('popstate', syncRouteFromLocation)
return () => window.removeEventListener('popstate', syncRouteFromLocation)
}, [syncRouteFromLocation])
const openDemo = useCallback(() => {
window.history.pushState({}, document.title, '/demo')
setIsDemoMode(true)
setIsViewerMode(false)
setIsAcceptingInvite(false)
}, [])
useEffect(() => {
registerNavigation({
setActiveTab,
@@ -153,9 +233,53 @@ function App() {
localStorage.setItem('active_logbook_title', title)
}
const openLogbookById = useCallback(
async (logbookId: string) => {
try {
const books = await fetchLogbooks()
const match = books.find((b) => b.id === logbookId)
if (match) {
selectLogbook(match.id, match.title)
return
}
} catch (err) {
console.error('Failed to resolve logbook from push:', err)
}
selectLogbook(logbookId, `${logbookId.slice(0, 8)}`)
},
[]
)
const consumePendingPushLogbook = useCallback(() => {
const pending = sessionStorage.getItem(PENDING_PUSH_LOGBOOK_KEY)
if (!pending) return
sessionStorage.removeItem(PENDING_PUSH_LOGBOOK_KEY)
void openLogbookById(pending)
}, [openLogbookById])
useEffect(() => {
if (isAuthenticated) {
consumePendingPushLogbook()
}
}, [isAuthenticated, consumePendingPushLogbook])
useEffect(() => {
if (!isAuthenticated || !('serviceWorker' in navigator)) return
const onSwMessage = (event: MessageEvent) => {
if (event.data?.type === 'OPEN_LOGBOOK' && typeof event.data.logbookId === 'string') {
void openLogbookById(event.data.logbookId)
}
}
navigator.serviceWorker.addEventListener('message', onSwMessage)
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
}, [isAuthenticated, openLogbookById])
const handleAuthenticated = async () => {
setIsAuthenticated(true)
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
void ensurePushSubscriptionIfEnabled()
try {
const demo = await seedDemoLogbookIfNeeded()
@@ -165,6 +289,7 @@ function App() {
setDemoHighlightEntryId(demo.firstEntryId)
}
requestStartAfterLogin()
consumePendingPushLogbook()
return
}
} catch (err) {
@@ -177,6 +302,7 @@ function App() {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
}
consumePendingPushLogbook()
}
const handleLogout = () => {
@@ -203,6 +329,19 @@ function App() {
i18n.changeLanguage(nextLang)
}
const handleExitDemo = () => {
window.history.replaceState({}, document.title, '/')
syncRouteFromLocation()
}
if (isDemoMode) {
return (
<div style={{ display: 'contents' }}>
<DemoViewer onExit={handleExitDemo} />
</div>
)
}
if (isViewerMode) {
return (
<div style={{ display: 'contents' }}>
@@ -235,7 +374,7 @@ function App() {
if (!isAuthenticated) {
return (
<div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} />
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
</div>
)
}
@@ -267,8 +406,17 @@ function App() {
{t('nav.dashboard')}
</button>
<div className="app-title-area">
<h2>{activeLogbookTitle}</h2>
<p className="app-subtitle">{t('app.name')} / {activeLogbookId.substring(0, 8)}...</p>
<div className="app-title-row">
<h2>{activeLogbookTitle}</h2>
{activeAccessRole !== 'OWNER' && (
<LogbookRoleBadge role={activeAccessRole} />
)}
</div>
<p className="app-subtitle">
{activeAccessRole !== 'OWNER'
? t('dashboard.section_shared_hint')
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
</p>
</div>
</div>
@@ -385,7 +533,10 @@ function App() {
*/}
{activeTab === 'settings' && (
<SettingsForm logbookId={activeLogbookId} />
<SettingsForm
logbookId={activeLogbookId}
onLogbookRestored={selectLogbook}
/>
)}
</main>
</div>
+4 -3
View File
@@ -7,9 +7,10 @@ export default function AppFooter() {
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a className="app-version-footer__copyright" href="mailto:elpatron+kd@mailbox.org">
© 2026 Markus F.J. Busche
</a>
<span className="app-version-footer__copyright">
© 2026 KnorrLabs/
<a href="mailto:elpatron+kd@mailbox.org">Markus F.J. Busche</a>
</span>
</footer>
)
}
+2 -1
View File
@@ -25,6 +25,7 @@ export default function AppTourOverlay() {
const { t } = useTranslation()
const {
isActive,
isDemoTour,
currentStepId,
currentStepIndex,
totalSteps,
@@ -104,7 +105,7 @@ export default function AppTourOverlay() {
if (!isActive || !currentStepId) return null
const { title, body } = getTourStepCopy(currentStepId, t)
const { title, body } = getTourStepCopy(currentStepId, t, { demoMode: isDemoTour })
const centered = isCenteredTourStep(currentStepId)
const tooltipStyle = centered
+12 -1
View File
@@ -16,9 +16,10 @@ import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
interface AuthOnboardingProps {
onAuthenticated: () => void
onOpenDemo?: () => void
}
export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) {
export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) {
const { t, i18n } = useTranslation()
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
@@ -523,6 +524,16 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.1)' }}></div>
</div>
<button
type="button"
className="btn secondary"
onClick={() => onOpenDemo?.()}
disabled={loading}
style={{ width: '100%' }}
>
{t('auth.explore_demo')}
</button>
{/* Registration form */}
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<div className="input-group">
+148
View File
@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
import { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
interface DemoViewerProps {
onExit: () => void
}
export default function DemoViewer({ onExit }: DemoViewerProps) {
const { t, i18n } = useTranslation()
const { registerNavigation, registerDemoTourContext, startTour } = useAppTour()
const [activeTab, setActiveTab] = useState<AppTab>('logs')
const [tourSelectedEntryId, setTourSelectedEntryId] = useState<string | null>(null)
const [fixture, setFixture] = useState<PublicDemoFixture>(() => buildPublicDemoFixture())
useEffect(() => {
trackPlausibleEvent(PlausibleEvents.DEMO_OPENED)
}, [])
useEffect(() => {
setFixture(buildPublicDemoFixture())
}, [i18n.language])
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId
})
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
const timer = window.setTimeout(() => {
startTour({ force: true, demoMode: true })
}, 400)
return () => {
window.clearTimeout(timer)
registerDemoTourContext(null)
}
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
}
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
return (
<div className="app-layout">
<div className="sync-progress-bar" style={{ height: '4px', background: 'linear-gradient(90deg, #f59e0b, #3b82f6)' }} />
<header className="app-header" style={{ borderBottom: '1px solid rgba(245, 158, 11, 0.25)' }}>
<div className="app-header-left">
<button className="btn-back" onClick={onExit}>
<ChevronLeft size={16} />
{t('demo.back_to_login')}
</button>
<div className="app-title-area">
<div className="app-title-row">
<h2>{title}</h2>
<span className="demo-badge">{t('demo.badge')}</span>
</div>
<p className="app-subtitle" style={{ color: '#f59e0b', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Lock size={12} />
<span>{t('demo.public_banner')}</span>
</p>
</div>
</div>
<div className="header-actions">
<button
className="btn primary"
onClick={onExit}
style={{ width: 'auto', padding: '6px 14px', fontSize: '13px' }}
>
<UserPlus size={14} style={{ marginRight: '4px' }} />
{t('demo.cta_register')}
</button>
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
</button>
</div>
</header>
<div className="app-body">
<aside className="app-sidebar">
<button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')}
data-tour="nav-logs"
>
<FileText size={18} />
{t('nav.logs')}
</button>
<button
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => setActiveTab('vessel')}
data-tour="nav-vessel"
>
<Ship size={18} />
{t('nav.vessel')}
</button>
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
data-tour="nav-crew"
>
<Users size={18} />
{t('nav.crew')}
</button>
</aside>
<main className="app-content">
{activeTab === 'logs' && (
<LogEntriesList
logbookId="demo"
readOnly={true}
preloadedYacht={yacht}
preloadedEntries={entries}
preloadedPhotos={photos}
preloadedGpsTracks={gpsTracks}
controlledSelectedEntryId={tourSelectedEntryId}
onSelectedEntryIdChange={setTourSelectedEntryId}
highlightEntryId={firstEntryId}
/>
)}
{activeTab === 'vessel' && (
<VesselForm logbookId="demo" readOnly={true} preloadedData={yacht} />
)}
{activeTab === 'crew' && (
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
)}
</main>
</div>
</div>
)
}
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from 'lucide-react'
import CaptainCap from './icons/CaptainCap.tsx'
import type { SkipperSignStatus } from '../utils/signatures.js'
interface EntrySkipperSignBadgeProps {
status: SkipperSignStatus
}
export default function EntrySkipperSignBadge({ status }: EntrySkipperSignBadgeProps) {
const { t } = useTranslation()
if (status === 'none') return null
const isValid = status === 'valid'
const label = isValid
? t('logs.sign_badge_skipper_title_valid')
: t('logs.sign_badge_skipper_title_invalid')
return (
<span
className={`entry-sign-badge entry-sign-badge--skipper ${isValid ? 'valid' : 'invalid'}`}
title={label}
>
{isValid ? <CaptainCap size={14} aria-hidden /> : <AlertTriangle size={12} aria-hidden />}
<span className={isValid ? 'entry-sign-badge__sr-label' : undefined}>
{isValid ? t('logs.sign_badge_skipper') : t('logs.sign_badge_skipper_invalid')}
</span>
</span>
)
}
@@ -10,6 +10,7 @@ import {
} from '../services/auth.js'
import { decryptJson, encryptBuffer } from '../services/crypto.js'
import { saveLogbookKey } from '../services/logbookKeys.js'
import { parseCollaborationRole } from '../services/logbook.js'
import { syncLogbook } from '../services/sync.js'
import { db } from '../services/db.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -182,6 +183,9 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
throw new Error(serverError.error || (isDe ? 'Beitritt auf dem Server fehlgeschlagen.' : 'Failed to join logbook on the server.'))
}
const acceptResult = await res.json()
const collaborationRole = parseCollaborationRole(acceptResult.role, 'invitation accept')
await saveLogbookKey(logbookId, logbookKey)
if (rawEncryptedTitle) {
@@ -190,7 +194,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
encryptedTitle: rawEncryptedTitle,
updatedAt: new Date().toISOString(),
isSynced: 1,
isShared: 1
isShared: 1,
collaborationRole
})
}
+18 -9
View File
@@ -9,7 +9,9 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
import {
carryOverFromPreviousDay,
@@ -41,6 +43,7 @@ interface DecryptedEntryItem {
departure: string
destination: string
updatedAt: string
skipperSignStatus: SkipperSignStatus
}
export default function LogEntriesList({
@@ -79,14 +82,18 @@ export default function LogEntriesList({
setError(null)
try {
if (readOnly && preloadedEntries) {
const list = preloadedEntries.map((entry: any) => ({
id: entry.payloadId || entry.id,
date: entry.date || '',
dayOfTravel: entry.dayOfTravel || '',
departure: entry.departure || '',
destination: entry.destination || '',
updatedAt: entry.updatedAt || new Date().toISOString()
}))
const list: DecryptedEntryItem[] = []
for (const entry of preloadedEntries) {
list.push({
id: entry.payloadId || entry.id,
date: entry.date || '',
dayOfTravel: entry.dayOfTravel || '',
departure: entry.departure || '',
destination: entry.destination || '',
updatedAt: entry.updatedAt || new Date().toISOString(),
skipperSignStatus: await getSkipperSignStatus(entry)
})
}
list.sort((a, b) => {
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
@@ -114,7 +121,8 @@ export default function LogEntriesList({
dayOfTravel: decrypted.dayOfTravel || '',
departure: decrypted.departure || '',
destination: decrypted.destination || '',
updatedAt: entry.updatedAt
updatedAt: entry.updatedAt,
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
})
}
}
@@ -411,6 +419,7 @@ export default function LogEntriesList({
<span className="sync-badge synced">
{t('logs.day_of_travel')} {item.dayOfTravel}
</span>
<EntrySkipperSignBadge status={item.skipperSignStatus} />
<span className="date-badge">
{new Date(item.date).toLocaleDateString()}
</span>
+303 -110
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
@@ -6,20 +6,21 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.tsx'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
normalizeSignature,
serializeSignature,
fingerprintSignature,
normalizedSerializedSignature,
isPasskeySignature,
isSignatureValidForEntry,
hasAnySignature
} from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
import { buildLogEntryPayload, type LogEventPayload } from '../utils/logEntryPayload.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
@@ -34,6 +35,56 @@ import {
} from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
function emptyTankLevels() {
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
}
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
const trackDistance = decrypted.trackDistanceNm
const trackSpeedMax = decrypted.trackSpeedMaxKn
const trackSpeedAvg = decrypted.trackSpeedAvgKn
const payload = buildLogEntryPayload({
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
departure: String(decrypted.departure || ''),
destination: String(decrypted.destination || ''),
freshwater: {
morning: fw.morning || 0,
refilled: fw.refilled || 0,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
},
fuel: {
morning: fuel.morning || 0,
refilled: fuel.refilled || 0,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
},
trackDistanceNm:
trackDistance != null && trackDistance !== ''
? parseFloat(String(trackDistance))
: undefined,
trackSpeedMaxKn:
trackSpeedMax != null && trackSpeedMax !== ''
? parseFloat(String(trackSpeedMax))
: undefined,
trackSpeedAvgKn:
trackSpeedAvg != null && trackSpeedAvg !== ''
? parseFloat(String(trackSpeedAvg))
: undefined,
events: (decrypted.events as LogEventPayload[]) || []
})
return JSON.stringify({
...payload,
signSkipper: fingerprintSignature(decrypted.signSkipper),
signCrew: fingerprintSignature(decrypted.signCrew)
})
}
interface LogEntryEditorProps {
entryId: string
logbookId: string
@@ -45,24 +96,7 @@ interface LogEntryEditorProps {
preloadedYacht?: any
}
interface LogEvent {
time: string
mgk: string
rwk: string
windPressure: string
windDirection: string
windStrength: string
seaState: string
weatherIcon: string
current: string
heel: string
sailsOrMotor: string
logReading: string
distance: string
gpsLat: string
gpsLng: string
remarks: string
}
interface LogEvent extends LogEventPayload {}
export default function LogEntryEditor({
entryId,
@@ -76,6 +110,8 @@ export default function LogEntryEditor({
}: LogEntryEditorProps) {
const { t, i18n } = useTranslation()
const { showAlert, showConfirm } = useDialog()
const showAlertRef = useRef(showAlert)
showAlertRef.current = showAlert
// General details state
const [date, setDate] = useState('')
@@ -137,6 +173,7 @@ export default function LogEntryEditor({
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
// Track file upload
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
@@ -145,6 +182,9 @@ export default function LogEntryEditor({
const fileInputRef = useRef<HTMLInputElement | null>(null)
const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false)
const lastSignatureAlertHashRef = useRef<string | null>(null)
const skipCrewSignClearRef = useRef(false)
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
const stats = computeTrackStats(waypoints)
@@ -167,7 +207,7 @@ export default function LogEntryEditor({
}
}
const buildPayloadForSigning = useCallback(() => {
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
return buildLogEntryPayload({
date,
dayOfTravel,
@@ -188,7 +228,7 @@ export default function LogEntryEditor({
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
events
events: eventsOverride ?? events
})
}, [
date, dayOfTravel, departure, destination,
@@ -198,6 +238,61 @@ export default function LogEntryEditor({
events
])
const currentFingerprint = useMemo(() => {
const payload = buildPayloadForSigning()
return JSON.stringify({
...payload,
signSkipper: fingerprintSignature(signSkipper),
signCrew: fingerprintSignature(signCrew)
})
}, [buildPayloadForSigning, signSkipper, signCrew])
const isDirty = savedFingerprint !== null && currentFingerprint !== savedFingerprint
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
if (readOnly) return
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryData = {
...buildPayloadForSigning(eventsOverride),
signSkipper: normalizedSerializedSignature(signSkipper),
signCrew: normalizedSerializedSignature(signCrew)
}
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
setSavedFingerprint(JSON.stringify({
...buildPayloadForSigning(eventsOverride),
signSkipper: fingerprintSignature(signSkipper),
signCrew: fingerprintSignature(signCrew)
}))
}, [
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
@@ -250,14 +345,21 @@ export default function LogEntryEditor({
if (entryHash !== lockedContentHashRef.current) {
lockedContentHashRef.current = null
setSignSkipper('')
setSignCrew('')
void showAlert(
t('logs.sign_cleared_re_sign'),
t('logs.sign_cleared_re_sign_title')
)
const hadSkipper = !!signSkipper
const hadCrew = !!signCrew
const skipperOnly = skipCrewSignClearRef.current
skipCrewSignClearRef.current = false
if (hadSkipper) setSignSkipper('')
if (hadCrew && !skipperOnly) setSignCrew('')
if (lastSignatureAlertHashRef.current !== entryHash && (hadSkipper || (hadCrew && !skipperOnly))) {
lastSignatureAlertHashRef.current = entryHash
void showAlertRef.current(
skipperOnly ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'),
skipperOnly ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title')
)
}
}
}, [entryHash, signSkipper, signCrew, readOnly, showAlert, t])
}, [entryHash, signSkipper, signCrew, readOnly, t])
const confirmSignWarning = useCallback(async (): Promise<boolean> => {
return showConfirm(
@@ -353,8 +455,10 @@ export default function LogEntryEditor({
async function loadEntry() {
setLoading(true)
setError(null)
setSavedFingerprint(null)
lockedContentHashRef.current = null
contentReadyRef.current = false
lastSignatureAlertHashRef.current = null
try {
if (readOnly && preloadedEntry) {
setDate(preloadedEntry.date || '')
@@ -379,6 +483,7 @@ export default function LogEntryEditor({
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || [])
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
return
}
@@ -411,6 +516,7 @@ export default function LogEntryEditor({
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || [])
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
}
}
} catch (err: any) {
@@ -426,7 +532,12 @@ export default function LogEntryEditor({
const loadTrack = async () => {
if (readOnly && preloadedTrack) {
setSavedTrack(preloadedTrack)
setSavedTrack({
waypoints: preloadedTrack.waypoints ?? [],
gpxContent: preloadedTrack.gpxContent ?? '',
filename: preloadedTrack.filename ?? 'track.gpx',
fileType: preloadedTrack.fileType ?? 'gpx'
})
return
}
try {
@@ -680,32 +791,26 @@ export default function LogEntryEditor({
return currentItems.includes(item.toLowerCase())
}
const handleAddEvent = (e: React.FormEvent) => {
e.preventDefault()
if (readOnly || !evTime) return
const buildEventFromForm = (): LogEvent => ({
time: evTime,
mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
})
const newEvent: LogEvent = {
time: evTime,
mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
}
setEvents((prev) => [...prev, newEvent])
// Clear event form fields
const clearEventForm = () => {
setEvTime('')
setEvMgk('')
setEvRwk('')
@@ -723,11 +828,103 @@ export default function LogEntryEditor({
setEvGpsLng('')
setEvRemarks('')
setEvLocationName('')
setEditingEventIndex(null)
}
const handleDeleteEvent = (index: number) => {
const fillEventForm = (ev: LogEvent) => {
setEvTime(ev.time)
setEvMgk(ev.mgk)
setEvRwk(ev.rwk)
setEvWindPressure(ev.windPressure)
setEvWindDirection(ev.windDirection)
setEvWindStrength(ev.windStrength)
setEvSeaState(ev.seaState)
setEvWeatherIcon(ev.weatherIcon)
setEvCurrent(ev.current)
setEvHeel(ev.heel)
setEvSailsOrMotor(ev.sailsOrMotor)
setEvLogReading(ev.logReading)
setEvDistance(ev.distance)
setEvGpsLat(ev.gpsLat)
setEvGpsLng(ev.gpsLng)
setEvRemarks(ev.remarks)
setEvLocationName('')
}
const markSkipperSignatureClearedForEventChange = () => {
if (!signSkipper) return
skipCrewSignClearRef.current = true
setSignSkipper('')
}
const handleEditEvent = (index: number) => {
if (readOnly) return
setEvents((prev) => prev.filter((_, idx) => idx !== index))
const ev = events[index]
if (!ev) return
fillEventForm(ev)
setEditingEventIndex(index)
}
const handleCancelEventEdit = () => {
clearEventForm()
}
const handleSaveEvent = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly || !evTime) return
const eventData = buildEventFromForm()
let nextEvents: LogEvent[]
if (editingEventIndex !== null) {
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange()
nextEvents = events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev))
if (hadSkipperSignature) {
void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title')
)
}
} else {
nextEvents = [...events, eventData]
}
setEvents(nextEvents)
clearEventForm()
try {
await persistEntryToDb(nextEvents)
} catch (err: any) {
console.error('Failed to auto-save event:', err)
setError(err.message || 'Failed to save event.')
}
}
const handleDeleteEvent = async (index: number) => {
if (readOnly) return
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange()
const nextEvents = events.filter((_, idx) => idx !== index)
setEvents(nextEvents)
if (hadSkipperSignature) {
void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title')
)
}
if (editingEventIndex === index) {
clearEventForm()
} else if (editingEventIndex !== null && index < editingEventIndex) {
setEditingEventIndex(editingEventIndex - 1)
}
try {
await persistEntryToDb(nextEvents)
} catch (err: any) {
console.error('Failed to auto-save after event delete:', err)
setError(err.message || 'Failed to save event deletion.')
}
}
const handleDownloadPdf = async () => {
@@ -746,45 +943,13 @@ export default function LogEntryEditor({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly) return
if (readOnly || !isDirty) return
setSaving(true)
setError(null)
setSuccess(false)
try {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryPayload = buildPayloadForSigning()
const entryData = {
...entryPayload,
signSkipper: serializeSignature(signSkipper),
signCrew: serializeSignature(signCrew)
}
// E2E encrypt
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
// Save locally
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
// Queue for background sync
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
await persistEntryToDb()
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
@@ -792,8 +957,6 @@ export default function LogEntryEditor({
setSuccess(false)
onBack()
}, 1500)
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to save entry details:', err)
setError(err.message || 'Failed to save entry details.')
@@ -1067,8 +1230,22 @@ export default function LogEntryEditor({
</td>
<td className="remarks-td">{ev.remarks}</td>
{!readOnly && (
<td>
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}>
<td className="events-actions-td">
<button
type="button"
className="btn-icon"
onClick={() => handleEditEvent(idx)}
title={t('logs.edit_event')}
disabled={editingEventIndex !== null && editingEventIndex !== idx}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => handleDeleteEvent(idx)}
title={t('logs.delete_event')}
>
<Trash2 size={14} />
</button>
</td>
@@ -1083,7 +1260,9 @@ export default function LogEntryEditor({
{/* Add New Event Form Sub-Card */}
{!readOnly && (
<div className="member-editor-card glass">
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>{t('logs.add_event')}</h4>
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>
{editingEventIndex !== null ? t('logs.edit_event') : t('logs.add_event')}
</h4>
<div className="form-grid mb-4">
<div className="input-group">
@@ -1317,16 +1496,30 @@ export default function LogEntryEditor({
</div>
</div>
<button
type="button"
className="btn secondary"
onClick={handleAddEvent}
disabled={saving || !evTime}
style={{ width: 'auto', padding: '10px 20px', marginLeft: 'auto', display: 'flex' }}
>
<Plus size={16} />
Add Event Entry
</button>
<div style={{ display: 'flex', gap: '8px', marginLeft: 'auto', flexWrap: 'wrap' }}>
{editingEventIndex !== null && (
<button
type="button"
className="btn secondary"
onClick={handleCancelEventEdit}
disabled={saving}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
<X size={16} />
{t('logs.cancel_event_edit')}
</button>
)}
<button
type="button"
className="btn secondary"
onClick={handleSaveEvent}
disabled={saving || !evTime}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
{editingEventIndex !== null ? t('logs.save_event_btn') : t('logs.add_event_btn')}
</button>
</div>
</div>
)}
</div>
@@ -1367,7 +1560,7 @@ export default function LogEntryEditor({
<Upload size={16} style={{ color: '#fbbf24' }} />
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
<span className="track-info-stats">
{savedTrack.fileType.toUpperCase()}
{(savedTrack.fileType ?? 'gpx').toUpperCase()}
{savedTrack.waypoints.length > 0 && (
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
)}
@@ -1483,7 +1676,7 @@ export default function LogEntryEditor({
</div>
)}
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim()}>
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim() || !isDirty}>
<Save size={18} />
{saving ? t('logs.saving') : t('logs.save')}
</button>
@@ -0,0 +1,327 @@
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Archive, Download, Upload, Check, AlertTriangle } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import {
downloadBackupBlob,
exportLogbookBackup,
parseLogbookBackupFile,
previewLogbookBackup,
restoreLogbookBackup,
type LogbookBackupFile,
type LogbookBackupPreview
} from '../services/logbookBackup.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
interface LogbookBackupPanelProps {
logbookId: string
onRestored?: (logbookId: string, title: string) => void
}
function mapBackupError(code: string, t: (key: string) => string): string {
switch (code) {
case 'BACKUP_PASSPHRASE_TOO_SHORT':
return t('settings.backup_passphrase_short')
case 'BACKUP_NOT_OWNER':
return t('settings.backup_not_owner')
case 'BACKUP_INVALID_JSON':
return t('settings.backup_invalid_json')
case 'BACKUP_INVALID_FORMAT':
return t('settings.backup_invalid_format')
case 'BACKUP_NOT_AUTHENTICATED':
return t('settings.backup_not_authenticated')
case 'BACKUP_ID_CONFLICT':
return t('settings.backup_id_conflict')
default:
if (code.includes('decrypt') || code.includes('operation')) {
return t('settings.backup_wrong_passphrase')
}
return code
}
}
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const fileInputRef = useRef<HTMLInputElement>(null)
const [exportPassphrase, setExportPassphrase] = useState('')
const [exportConfirm, setExportConfirm] = useState('')
const [exporting, setExporting] = useState(false)
const [importPassphrase, setImportPassphrase] = useState('')
const [importFile, setImportFile] = useState<File | null>(null)
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
const [parsedBackup, setParsedBackup] = useState<LogbookBackupFile | null>(null)
const [importing, setImporting] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const handleExport = async () => {
setError(null)
setSuccess(null)
if (exportPassphrase.length < 8) {
setError(t('settings.backup_passphrase_short'))
return
}
if (exportPassphrase !== exportConfirm) {
setError(t('settings.backup_passphrase_mismatch'))
return
}
setExporting(true)
try {
const { blob, filename, backup } = await exportLogbookBackup(logbookId, exportPassphrase)
downloadBackupBlob(blob, filename)
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries }))
setExportPassphrase('')
setExportConfirm('')
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
entries: backup.counts.entries,
photos: backup.counts.photos
})
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
setError(mapBackupError(message, t))
} finally {
setExporting(false)
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
setError(null)
setSuccess(null)
setImportPreview(null)
setParsedBackup(null)
const file = e.target.files?.[0]
setImportFile(file ?? null)
if (!file) return
try {
const backup = await parseLogbookBackupFile(file)
setParsedBackup(backup)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
setError(mapBackupError(message, t))
setImportFile(null)
}
}
const handlePreviewImport = async () => {
if (!parsedBackup || !importPassphrase) return
setPreviewing(true)
setError(null)
try {
const preview = await previewLogbookBackup(parsedBackup, importPassphrase)
setImportPreview(preview)
} catch (err: unknown) {
setImportPreview(null)
setError(t('settings.backup_wrong_passphrase'))
} finally {
setPreviewing(false)
}
}
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
if (!parsedBackup || !importPassphrase) return
setImporting(true)
setError(null)
try {
const result = await restoreLogbookBackup(parsedBackup, importPassphrase, options)
setSuccess(t('settings.backup_restore_success', { title: result.title }))
setImportFile(null)
setImportPassphrase('')
setImportPreview(null)
setParsedBackup(null)
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
entries: parsedBackup.counts.entries,
photos: parsedBackup.counts.photos,
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
})
onRestored?.(result.logbookId, result.title)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
if (message === 'BACKUP_ID_CONFLICT') {
const overwrite = await showConfirm(
t('settings.backup_overwrite_confirm'),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (overwrite) {
setImporting(false)
return handleRestore({ overwrite: true })
}
const asNew = await showConfirm(
t('settings.backup_new_id_confirm'),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (asNew) {
setImporting(false)
return handleRestore({ assignNewId: true })
}
setError(t('settings.backup_restore_cancelled'))
} else {
setError(mapBackupError(message, t))
}
} finally {
setImporting(false)
}
}
return (
<div className="member-editor-card glass mt-6 backup-panel" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Archive size={20} style={{ color: '#38bdf8' }} />
<h3 style={{ margin: 0, color: '#38bdf8', fontSize: '16px' }}>
{t('settings.backup_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 20px 0' }}>
{t('settings.backup_desc')}
</p>
{error && (
<div className="auth-error mb-4" role="alert">
<AlertTriangle size={16} style={{ display: 'inline', marginRight: 6, verticalAlign: 'text-bottom' }} />
{error}
</div>
)}
{success && (
<div className="success-toast mb-4">
<Check size={16} />
<span>{success}</span>
</div>
)}
<section className="backup-section" aria-labelledby="backup-export-heading">
<h4 id="backup-export-heading" className="backup-section-title">
<Download size={16} aria-hidden="true" />
{t('settings.backup_export_title')}
</h4>
<p className="text-muted backup-section-desc">{t('settings.backup_export_desc')}</p>
<div className="input-group">
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-export-passphrase"
type="password"
className="input-text"
value={exportPassphrase}
onChange={(e) => setExportPassphrase(e.target.value)}
placeholder={t('settings.backup_passphrase_placeholder')}
autoComplete="new-password"
disabled={exporting}
/>
</div>
<div className="input-group">
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
<input
id="backup-export-confirm"
type="password"
className="input-text"
value={exportConfirm}
onChange={(e) => setExportConfirm(e.target.value)}
autoComplete="new-password"
disabled={exporting}
/>
</div>
<button
type="button"
className="btn primary"
onClick={handleExport}
disabled={exporting || !exportPassphrase || !exportConfirm}
>
<Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button>
</section>
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
<h4 id="backup-import-heading" className="backup-section-title">
<Upload size={16} aria-hidden="true" />
{t('settings.backup_restore_title')}
</h4>
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
<div className="input-group">
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
<input
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok.json,application/json"
className="input-text"
onChange={handleFileChange}
disabled={importing}
/>
</div>
{importFile && (
<>
<div className="input-group">
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-import-passphrase"
type="password"
className="input-text"
value={importPassphrase}
onChange={(e) => {
setImportPassphrase(e.target.value)
setImportPreview(null)
}}
autoComplete="current-password"
disabled={importing}
/>
</div>
<div className="backup-actions-row">
<button
type="button"
className="btn secondary"
onClick={handlePreviewImport}
disabled={previewing || importing || !importPassphrase}
>
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button>
<button
type="button"
className="btn primary"
onClick={() => handleRestore()}
disabled={importing || !importPassphrase}
>
<Upload size={16} />
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
</button>
</div>
</>
)}
{importPreview && (
<div className="backup-preview glass">
<p className="backup-preview-title">{importPreview.title}</p>
<ul className="backup-preview-stats">
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
</ul>
<p className="text-muted backup-preview-date">
{t('settings.backup_exported_at', {
date: new Date(importPreview.exportedAt).toLocaleString()
})}
</p>
</div>
)}
</section>
</div>
)
}
+74 -37
View File
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '../services/db.js'
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
@@ -82,7 +83,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation() // Prevent selecting the logbook when clicking delete
if (await showConfirm(t('dashboard.delete_confirm'), t('dashboard.title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
if (await showConfirm(t('dashboard.delete_confirm'), t('dashboard.delete_btn'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
setLoading(true)
setError(null)
try {
@@ -106,6 +107,68 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
i18n.changeLanguage(nextLang)
}
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
const renderLogbookCard = (lb: DecryptedLogbook) => (
<div
key={lb.id}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
onClick={() => onSelectLogbook(lb.id, lb.title)}
>
<div className="card-icon">
<BookOpen size={24} />
</div>
<div className="card-info">
<div className="card-title-row">
<h3>{lb.title}</h3>
<LogbookRoleBadge role={lb.accessRole} />
</div>
<div className="card-meta">
<span className={`sync-badge ${lb.isSynced ? 'synced' : 'local'}`}>
{lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')}
</span>
{lb.isDemo && (
<span className="demo-badge">{t('demo.badge')}</span>
)}
<span className="date-badge">
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</span>
</div>
</div>
<button
className="btn-delete"
onClick={(e) => handleDelete(lb.id, e)}
title={t('dashboard.delete_btn')}
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
>
<Trash2 size={18} />
</button>
</div>
)
const renderLogbookSection = (
title: string,
items: DecryptedLogbook[],
hint?: string
) => (
<div className="logbook-section">
<div className="logbook-section-header">
<h3>{title}</h3>
{hint && <p className="logbook-section-hint">{hint}</p>}
</div>
<div className="logbooks-grid">
{items.map(renderLogbookCard)}
</div>
</div>
)
return (
<div className="dashboard-container">
{/* Premium Dashboard Header */}
@@ -201,42 +264,16 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
) : logbooks.length === 0 ? (
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
) : (
<div className="logbooks-grid">
{logbooks.map((lb) => (
<div key={lb.id} className="logbook-card glass" onClick={() => onSelectLogbook(lb.id, lb.title)}>
<div className="card-icon">
<BookOpen size={24} />
</div>
<div className="card-info">
<h3>{lb.title}</h3>
<div className="card-meta">
<span className={`sync-badge ${lb.isSynced ? 'synced' : 'local'}`}>
{lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')}
</span>
{lb.isDemo && (
<span className="demo-badge">{t('demo.badge')}</span>
)}
<span className="date-badge">
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</span>
</div>
</div>
<button
className="btn-delete"
onClick={(e) => handleDelete(lb.id, e)}
title={t('dashboard.delete_btn')}
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
>
<Trash2 size={18} />
</button>
</div>
))}
<div className="logbook-sections">
{ownedLogbooks.length > 0 && renderLogbookSection(
sharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
ownedLogbooks
)}
{sharedLogbooks.length > 0 && renderLogbookSection(
t('dashboard.section_shared'),
sharedLogbooks,
t('dashboard.section_shared_hint')
)}
</div>
)}
</section>
@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next'
import { Anchor, Eye, Users } from 'lucide-react'
import type { LogbookAccessRole } from '../services/logbook.js'
interface LogbookRoleBadgeProps {
role: LogbookAccessRole
className?: string
}
export default function LogbookRoleBadge({ role, className = '' }: LogbookRoleBadgeProps) {
const { t } = useTranslation()
if (role === 'OWNER') {
return (
<span className={`role-badge role-badge--owner ${className}`.trim()} title={t('dashboard.role_owner_hint')}>
<Anchor size={12} aria-hidden="true" />
{t('dashboard.role_owner')}
</span>
)
}
if (role === 'READ') {
return (
<span className={`role-badge role-badge--read ${className}`.trim()} title={t('dashboard.role_read_hint')}>
<Eye size={12} aria-hidden="true" />
{t('dashboard.role_read')}
</span>
)
}
return (
<span className={`role-badge role-badge--crew ${className}`.trim()} title={t('dashboard.role_crew_hint')}>
<Users size={12} aria-hidden="true" />
{t('dashboard.role_crew')}
</span>
)
}
+20 -10
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useRef } from 'react'
import React, { createContext, useContext, useState, useRef, useCallback, useMemo } from 'react'
interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
@@ -25,7 +25,7 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
const resolveRef = useRef<((val: any) => void) | null>(null)
const showAlert = (msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('alert')
@@ -35,9 +35,14 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
return new Promise<void>((resolve) => {
resolveRef.current = resolve
})
}
}, [])
const showConfirm = (msg: string, headerTitle?: string, btnConfirm?: string, btnCancel?: string): Promise<boolean> => {
const showConfirm = useCallback((
msg: string,
headerTitle?: string,
btnConfirm?: string,
btnCancel?: string
): Promise<boolean> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('confirm')
@@ -48,26 +53,31 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
})
}
}, [])
const handleConfirm = () => {
const handleConfirm = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(type === 'confirm' ? true : undefined)
resolveRef.current = null
}
}
}, [type])
const handleCancel = () => {
const handleCancel = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(false)
resolveRef.current = null
}
}
}, [])
const contextValue = useMemo(
() => ({ showAlert, showConfirm }),
[showAlert, showConfirm]
)
return (
<DialogContext.Provider value={{ showAlert, showConfirm }}>
<DialogContext.Provider value={contextValue}>
{children}
{isOpen && (
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
@@ -0,0 +1,135 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Bell, BellOff } from 'lucide-react'
import {
disableCollaboratorChangePush,
enableCollaboratorChangePush,
fetchPushPrefs,
getNotificationPermission,
isPushSupported
} from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { useDialog } from './ModalDialog.tsx'
export default function PushNotificationSettings() {
const { t } = useTranslation()
const { showAlert } = useDialog()
const [enabled, setEnabled] = useState(false)
const [loading, setLoading] = useState(true)
const [toggling, setToggling] = useState(false)
const supported = isPushSupported()
const permission = getNotificationPermission()
const iosNeedsInstall = isIosDevice() && !isRunningStandalone()
const loadPrefs = useCallback(async () => {
if (!supported) {
setLoading(false)
return
}
try {
const prefs = await fetchPushPrefs()
setEnabled(prefs.collaboratorChangesEnabled)
} catch (err) {
console.error('Failed to load push prefs:', err)
} finally {
setLoading(false)
}
}, [supported])
useEffect(() => {
void loadPrefs()
}, [loadPrefs])
const handleToggle = async (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.checked
setToggling(true)
try {
if (next) {
await enableCollaboratorChangePush()
setEnabled(true)
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
} else {
await disableCollaboratorChangePush()
setEnabled(false)
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('settings.push_error')
showAlert(message)
void loadPrefs()
} finally {
setToggling(false)
}
}
if (!supported) {
return (
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<BellOff size={20} style={{ color: '#94a3b8' }} />
<h3 style={{ margin: 0, color: '#94a3b8', fontSize: '16px' }}>{t('settings.push_title')}</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: 0 }}>
{t('settings.push_unsupported')}
</p>
</div>
)
}
return (
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Bell size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.push_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.push_desc')}
</p>
{iosNeedsInstall && (
<p className="text-muted" style={{ fontSize: '13px', margin: '0 0 12px 0' }}>
{t('settings.push_ios_install_hint')}
</p>
)}
{permission === 'denied' && (
<p style={{ fontSize: '13px', color: '#f87171', margin: '0 0 12px 0' }}>
{t('settings.push_denied_hint')}
</p>
)}
<label
className="switch-label"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: loading || toggling || iosNeedsInstall ? 'not-allowed' : 'pointer',
fontSize: '14px',
color: '#f1f5f9',
opacity: loading || iosNeedsInstall ? 0.6 : 1
}}
>
<input
type="checkbox"
checked={enabled}
onChange={handleToggle}
disabled={loading || toggling || iosNeedsInstall}
style={{ width: '18px', height: '18px', cursor: 'inherit' }}
/>
<span>{t('settings.push_enable')}</span>
</label>
{enabled && permission === 'granted' && (
<p className="text-muted" style={{ fontSize: '12px', margin: '12px 0 0 0' }}>
{t('settings.push_active')}
</p>
)}
</div>
)
}
+10 -1
View File
@@ -2,8 +2,10 @@ import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx'
import { useDialog } from './ModalDialog.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import ThemedSelect from './ThemedSelect.tsx'
@@ -12,6 +14,7 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
interface SettingsFormProps {
logbookId?: string | null
onLogbookRestored?: (logbookId: string, title: string) => void
}
interface Collaborator {
@@ -29,7 +32,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
.join('')
}
export default function SettingsForm({ logbookId }: SettingsFormProps) {
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog()
const { restartTour } = useAppTour()
@@ -295,6 +298,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
<form onSubmit={handleSubmit} className="vessel-form mt-6">
<PwaInstallPrompt variant="inline" />
<PushNotificationSettings />
{/* Weather Integration card */}
<div className="member-editor-card glass">
@@ -454,6 +458,11 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
</div>
)}
{/* Backup & Restore (owner only) */}
{logbookId && isOwner && (
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
)}
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
{logbookId && isOwner && (
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
@@ -0,0 +1,29 @@
import type { SVGProps } from 'react'
interface CaptainCapProps extends SVGProps<SVGSVGElement> {
size?: number | string
}
/** Skipper-/Kapitänsmütze im Lucide-Strichstil (nicht in lucide-react enthalten). */
export default function CaptainCap({ size = 24, ...props }: CaptainCapProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
{...props}
>
<path d="M5 11c0-3.5 3-6 7-6s7 2.5 7 6" />
<path d="M4 11h16" />
<path d="M4 11c0 2.5 3.2 4.5 8 4.5S20 13.5 20 11" />
<path d="M8 11h8" />
</svg>
)
}
+47 -10
View File
@@ -11,7 +11,8 @@ import {
import {
clearTourCompleted,
isTourCompleted,
markTourCompleted
markTourCompleted,
resolveTourUserId
} from '../services/appTourStorage.js'
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -33,18 +34,24 @@ interface TourNavigation {
setSelectedEntryId: (entryId: string | null) => void
}
interface DemoTourContext {
firstEntryId: string
}
interface AppTourContextValue {
isActive: boolean
isDemoTour: boolean
currentStepId: TourStepId | null
currentStepIndex: number
totalSteps: number
startTour: (options?: { force?: boolean }) => void
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
stopTour: () => void
restartTour: () => void
nextStep: () => void
prevStep: () => void
skipTour: () => void
registerNavigation: (navigation: TourNavigation) => void
registerDemoTourContext: (context: DemoTourContext | null) => void
requestStartAfterLogin: () => void
}
@@ -74,10 +81,17 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const [isActive, setIsActive] = useState(false)
const [stepIndex, setStepIndex] = useState(0)
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
const [isDemoTour, setIsDemoTour] = useState(false)
const navigationRef = useRef<TourNavigation | null>(null)
const demoContextRef = useRef<DemoTourContext | null>(null)
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
const currentStepId = isActive ? STEP_ORDER[stepIndex] ?? null : null
const resolveFirstEntryId = useCallback((): string | null => {
return demoContextRef.current?.firstEntryId ?? getStoredDemoFirstEntryId()
}, [])
const applyStepSideEffects = useCallback((stepId: TourStepId) => {
const nav = navigationRef.current
if (!nav) return
@@ -86,7 +100,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setActiveTab('logs')
}
if (stepId === 'entry_open' || stepId === 'entry_track') {
const firstEntryId = getStoredDemoFirstEntryId()
const firstEntryId = resolveFirstEntryId()
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
}
if (stepId === 'nav_vessel') {
@@ -97,7 +111,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setSelectedEntryId(null)
nav.setActiveTab('crew')
}
}, [])
}, [resolveFirstEntryId])
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
if (!stepId) return
@@ -109,24 +123,32 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
})
}, [])
const startTour = useCallback((options?: { force?: boolean }) => {
const userId = localStorage.getItem('active_userid')
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
const demoMode = options?.demoMode === true
const userId = resolveTourUserId({ demoMode })
if (!userId) return
if (!options?.force && isTourCompleted(userId)) return
tourModeRef.current = { demoMode }
setIsDemoTour(demoMode)
setStepIndex(0)
setIsActive(true)
}, [])
const dismissTour = useCallback((outcome: 'completed' | 'skipped', stepIndexAtDismiss: number) => {
const userId = localStorage.getItem('active_userid')
const userId = resolveTourUserId({ demoMode: tourModeRef.current.demoMode })
if (userId) markTourCompleted(userId)
const tourProps = tourModeRef.current.demoMode ? { mode: 'demo' } : undefined
if (outcome === 'completed') {
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED)
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
} else {
const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome'
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step })
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step, ...tourProps })
}
tourModeRef.current = { demoMode: false }
setIsDemoTour(false)
setIsActive(false)
setStepIndex(0)
}, [])
@@ -170,6 +192,10 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
navigationRef.current = navigation
}, [])
const registerDemoTourContext = useCallback((context: DemoTourContext | null) => {
demoContextRef.current = context
}, [])
const requestStartAfterLogin = useCallback(() => {
setPendingAfterLogin(true)
}, [])
@@ -191,6 +217,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const value = useMemo<AppTourContextValue>(
() => ({
isActive,
isDemoTour,
currentStepId,
currentStepIndex: stepIndex,
totalSteps: STEP_ORDER.length,
@@ -201,13 +228,16 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
prevStep,
skipTour,
registerNavigation,
registerDemoTourContext,
requestStartAfterLogin
}),
[
currentStepId,
isActive,
isDemoTour,
nextStep,
prevStep,
registerDemoTourContext,
registerNavigation,
requestStartAfterLogin,
restartTour,
@@ -231,8 +261,15 @@ export function useAppTour(): AppTourContextValue {
export function getTourStepCopy(
stepId: TourStepId,
t: (key: string) => string
t: (key: string) => string,
options?: { demoMode?: boolean }
): { title: string; body: string } {
if (stepId === 'welcome' && options?.demoMode) {
return {
title: t('tour.steps.welcome_public.title'),
body: t('tour.steps.welcome_public.body')
}
}
return {
title: t(`tour.steps.${stepId}.title`),
body: t(`tour.steps.${stepId}.body`)
+74 -4
View File
@@ -38,6 +38,7 @@
"error_incorrect_recovery": "Falscher Wiederherstellungsschlüssel. Entschlüsselung fehlgeschlagen.",
"error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfen Sie Ihren Wiederherstellungsschlüssel.",
"or_register": "oder Registrieren",
"explore_demo": "Demo ohne Account erkunden",
"username_placeholder": "Benutzername / Skippername",
"processing": "Verarbeitung...",
"help": "Hilfe",
@@ -114,6 +115,13 @@
"new_entry": "Neuer Reisetag",
"travel_details": "Reisedetails",
"add_event": "Neuen Logbucheintrag hinzufügen",
"add_event_btn": "Ereignis hinzufügen",
"edit_event": "Ereignis bearbeiten",
"save_event_btn": "Änderung speichern",
"cancel_event_edit": "Abbrechen",
"delete_event": "Ereignis löschen",
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
"date": "Datum",
"day_of_travel": "Tag der Reise / Reisetag",
"departure": "Start-Hafen (Reise von)",
@@ -141,6 +149,10 @@
"sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen",
"sign_passkey_cancelled": "Passkey-Freigabe abgebrochen",
"sign_invalid": "Signatur ungültig — Inhalt wurde geändert",
"sign_badge_skipper": "Skipper",
"sign_badge_skipper_invalid": "Ungültig",
"sign_badge_skipper_title_valid": "Skipper hat freigegeben",
"sign_badge_skipper_title_invalid": "Skipper-Signatur ungültig — Inhalt wurde geändert",
"sign_classic_or_passkey": "Optional: klassisch unterschreiben oder Passkey-Freigabe oben",
"sign_crew_passkey_hint": "Crew-Mitglieder mit Schreibzugriff können per Passkey freigeben",
"sign_offline_hint": "Passkey-Freigabe erfordert Internet — klassische Unterschrift offline möglich",
@@ -231,12 +243,21 @@
"create_btn": "Logbuch erstellen",
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
"logout": "Abmelden",
"delete_confirm": "Sind Sie sicher, dass Sie dieses Logbuch unwiderruflich löschen möchten? Alle lokalen Daten und Server-Backups werden vernichtet.",
"delete_confirm": "Sind Sie sicher, dass Sie dieses Logbuch unwiderruflich löschen möchten? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstellen Sie vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls Sie die Daten später behalten möchten.",
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...",
"status_synced": "Synchronisiert",
"status_local": "Nur lokaler Cache",
"delete_btn": "Logbuch löschen"
"delete_btn": "Logbuch löschen",
"section_owned": "Meine Logbücher",
"section_shared": "Geteilte Logbücher",
"section_shared_hint": "Sie wurden als Crew-Mitglied eingeladen. Skipper-Profil und Einstellungen gehören dem Eigner.",
"role_owner": "Eigenes Logbuch",
"role_owner_hint": "Sie sind Eigner und Skipper dieses Logbuchs",
"role_crew": "Crew-Zugang",
"role_crew_hint": "Eingeladenes Logbuch — Sie können als Crew mitarbeiten und signieren",
"role_read": "Nur Lesen",
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung"
},
"crew": {
"title": "Skipper- & Crew-Profile",
@@ -312,7 +333,49 @@
"deleting_account": "Konto wird gelöscht…",
"tour_title": "App-Tour",
"tour_desc": "Lassen Sie sich erneut durch die wichtigsten Bereiche der App führen.",
"tour_restart": "Tour erneut starten"
"tour_restart": "Tour erneut starten",
"push_title": "Push-Benachrichtigungen",
"push_desc": "Als Logbuch-Eigner werden Sie benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.",
"push_enable": "Bei Crew-Änderungen benachrichtigen",
"push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlauben Sie sie in den Browser- oder Geräteeinstellungen.",
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
"backup_title": "Backup & Wiederherstellung",
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
"backup_export_title": "Backup erstellen",
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahren Sie Datei und Passphrase getrennt und sicher auf.",
"backup_restore_title": "Backup wiederherstellen",
"backup_restore_desc": "Stellt ein Backup in Ihrem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
"backup_passphrase": "Backup-Passphrase",
"backup_passphrase_placeholder": "Mindestens 8 Zeichen",
"backup_passphrase_confirm": "Passphrase bestätigen",
"backup_passphrase_short": "Die Backup-Passphrase muss mindestens 8 Zeichen lang sein.",
"backup_passphrase_mismatch": "Passphrasen stimmen nicht überein.",
"backup_wrong_passphrase": "Passphrase falsch oder Backup beschädigt.",
"backup_export_btn": "Backup herunterladen",
"backup_exporting": "Backup wird erstellt…",
"backup_export_success": "Backup erstellt ({{count}} Reisetage).",
"backup_file_label": "Backup-Datei (.daagbok.json)",
"backup_preview_btn": "Inhalt prüfen",
"backup_previewing": "Prüfe…",
"backup_restore_btn": "Wiederherstellen",
"backup_restoring": "Wird wiederhergestellt…",
"backup_restore_success": "Logbuch „{{title}}“ wurde wiederhergestellt.",
"backup_restore_cancelled": "Wiederherstellung abgebrochen.",
"backup_invalid_json": "Die Datei ist keine gültige JSON-Datei.",
"backup_invalid_format": "Unbekanntes oder veraltetes Backup-Format.",
"backup_not_owner": "Nur der Logbuch-Eigner kann Backups erstellen.",
"backup_not_authenticated": "Bitte melden Sie sich an, um ein Backup wiederherzustellen.",
"backup_id_conflict": "Ein Logbuch mit dieser ID existiert bereits.",
"backup_overwrite_confirm": "Das vorhandene Logbuch mit gleicher ID wird ersetzt. Fortfahren?",
"backup_new_id_confirm": "Das Backup als neues Logbuch mit neuer ID importieren?",
"backup_stat_entries": "{{count}} Reisetage",
"backup_stat_photos": "{{count}} Fotos",
"backup_stat_crew": "{{count}} Crew-Einträge",
"backup_stat_tracks": "{{count}} GPS-Tracks",
"backup_exported_at": "Exportiert: {{date}}"
},
"disclaimer": {
"title": "Wichtige Hinweise",
@@ -336,7 +399,10 @@
},
"demo": {
"logbook_title": "Demo-Logbuch Ostsee",
"badge": "Demo"
"badge": "Demo",
"public_banner": "Schreibgeschützte Demo-Ansicht",
"cta_register": "Account erstellen",
"back_to_login": "Zur Anmeldung"
},
"stats": {
"title": "Statistik",
@@ -382,6 +448,10 @@
"title": "Willkommen an Bord!",
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen."
},
"welcome_public": {
"title": "Willkommen an Bord!",
"body": "Erkunden Sie unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese kurze Tour zeigt Ihnen Schiffsdaten, Crew und Logbucheinträge."
},
"nav_logs": {
"title": "Logbucheinträge",
"body": "Hier verwalten Sie Ihre Reisetage Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
+74 -4
View File
@@ -38,6 +38,7 @@
"error_incorrect_recovery": "Incorrect recovery phrase. Decryption failed.",
"error_decryption_failed": "Decryption failed. Please check your recovery phrase.",
"or_register": "or register",
"explore_demo": "Explore demo without account",
"username_placeholder": "Username / Skipper Name",
"processing": "Processing...",
"help": "Help",
@@ -114,6 +115,13 @@
"new_entry": "New Travel Day",
"travel_details": "Travel Details",
"add_event": "Add Event Log Record",
"add_event_btn": "Add Event Entry",
"edit_event": "Edit event",
"save_event_btn": "Save changes",
"cancel_event_edit": "Cancel",
"delete_event": "Delete event",
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
"date": "Date",
"day_of_travel": "Day of Travel",
"departure": "Departure Port (von)",
@@ -141,6 +149,10 @@
"sign_passkey_failed": "Passkey signing failed",
"sign_passkey_cancelled": "Passkey signing cancelled",
"sign_invalid": "Signature invalid — entry content changed",
"sign_badge_skipper": "Skipper",
"sign_badge_skipper_invalid": "Invalid",
"sign_badge_skipper_title_valid": "Signed by skipper",
"sign_badge_skipper_title_invalid": "Skipper signature invalid — entry content changed",
"sign_classic_or_passkey": "Optional: sign classically below or use Passkey above",
"sign_crew_passkey_hint": "Write collaborators can sign with their Passkey",
"sign_offline_hint": "Passkey signing requires internet — classic signature works offline",
@@ -231,12 +243,21 @@
"create_btn": "Create Logbook",
"new_logbook_placeholder": "Logbook or Yacht Name",
"logout": "Logout",
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local cache and server backups will be destroyed.",
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.",
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...",
"status_synced": "Synced",
"status_local": "Local Cache Only",
"delete_btn": "Delete logbook"
"delete_btn": "Delete logbook",
"section_owned": "My logbooks",
"section_shared": "Shared logbooks",
"section_shared_hint": "You were invited as crew. Skipper profile and settings belong to the owner.",
"role_owner": "Own logbook",
"role_owner_hint": "You own this logbook and act as skipper",
"role_crew": "Crew access",
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
"role_read": "Read only",
"role_read_hint": "Shared logbook — view only, no editing"
},
"crew": {
"title": "Skipper & Crew Profiles",
@@ -312,7 +333,49 @@
"deleting_account": "Deleting account…",
"tour_title": "App tour",
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
"tour_restart": "Restart tour"
"tour_restart": "Restart tour",
"push_title": "Push notifications",
"push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.",
"push_enable": "Notify on crew changes",
"push_active": "Push notifications are active on this device.",
"push_unsupported": "Push notifications are not supported in this browser.",
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
"push_error": "Could not enable push notifications.",
"backup_title": "Backup & restore",
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
"backup_export_title": "Create backup",
"backup_export_desc": "Downloads all local data as a .daagbok.json file. Keep the file and passphrase separate and secure.",
"backup_restore_title": "Restore backup",
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
"backup_passphrase": "Backup passphrase",
"backup_passphrase_placeholder": "At least 8 characters",
"backup_passphrase_confirm": "Confirm passphrase",
"backup_passphrase_short": "The backup passphrase must be at least 8 characters.",
"backup_passphrase_mismatch": "Passphrases do not match.",
"backup_wrong_passphrase": "Wrong passphrase or corrupted backup.",
"backup_export_btn": "Download backup",
"backup_exporting": "Creating backup…",
"backup_export_success": "Backup created ({{count}} travel days).",
"backup_file_label": "Backup file (.daagbok.json)",
"backup_preview_btn": "Verify contents",
"backup_previewing": "Verifying…",
"backup_restore_btn": "Restore",
"backup_restoring": "Restoring…",
"backup_restore_success": "Logbook “{{title}}” has been restored.",
"backup_restore_cancelled": "Restore cancelled.",
"backup_invalid_json": "The file is not valid JSON.",
"backup_invalid_format": "Unknown or outdated backup format.",
"backup_not_owner": "Only the logbook owner can create backups.",
"backup_not_authenticated": "Please sign in to restore a backup.",
"backup_id_conflict": "A logbook with this ID already exists.",
"backup_overwrite_confirm": "The existing logbook with the same ID will be replaced. Continue?",
"backup_new_id_confirm": "Import the backup as a new logbook with a new ID?",
"backup_stat_entries": "{{count}} travel days",
"backup_stat_photos": "{{count}} photos",
"backup_stat_crew": "{{count}} crew records",
"backup_stat_tracks": "{{count}} GPS tracks",
"backup_exported_at": "Exported: {{date}}"
},
"disclaimer": {
"title": "Important notice",
@@ -336,7 +399,10 @@
},
"demo": {
"logbook_title": "Baltic Sea Demo Logbook",
"badge": "Demo"
"badge": "Demo",
"public_banner": "Read-only demo view",
"cta_register": "Create account",
"back_to_login": "Back to login"
},
"stats": {
"title": "Statistics",
@@ -382,6 +448,10 @@
"title": "Welcome aboard!",
"body": "We created a demo logbook with three travel days in Kiel Bay. This short tour shows you the key features."
},
"welcome_public": {
"title": "Welcome aboard!",
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. This short tour shows vessel data, crew, and log entries."
},
"nav_logs": {
"title": "Log entries",
"body": "Manage your travel days here departure, destination, weather, tank levels, and GPS tracks."
+6 -1
View File
@@ -17,7 +17,12 @@ export const PlausibleEvents = {
PDF_EXPORTED: 'PDF Exported',
CSV_EXPORTED: 'CSV Exported',
CSV_SHARED: 'CSV Shared',
PHOTO_UPLOADED: 'Photo Uploaded'
PHOTO_UPLOADED: 'Photo Uploaded',
BACKUP_EXPORTED: 'Backup Exported',
BACKUP_RESTORED: 'Backup Restored',
DEMO_OPENED: 'Demo Opened',
PUSH_ENABLED: 'Push Enabled',
PUSH_DISABLED: 'Push Disabled'
} as const
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
+9
View File
@@ -1,3 +1,5 @@
export const PUBLIC_DEMO_TOUR_USER_ID = '__public_demo__'
export function getTourCompletedKey(userId: string): string {
return `app_tour_completed_${userId}`
}
@@ -14,3 +16,10 @@ export function markTourCompleted(userId: string): void {
export function clearTourCompleted(userId: string): void {
localStorage.removeItem(getTourCompletedKey(userId))
}
export function resolveTourUserId(options?: { demoMode?: boolean }): string | null {
const activeUserId = localStorage.getItem('active_userid')
if (activeUserId) return activeUserId
if (options?.demoMode) return PUBLIC_DEMO_TOUR_USER_ID
return null
}
+1
View File
@@ -7,6 +7,7 @@ export interface LocalLogbook {
isSynced: number // 1 = yes, 0 = pending local modifications
isShared?: number // 1 = collaborator copy, 0 or unset = owned
isDemo?: number // 1 = demo logbook seeded at registration
collaborationRole?: 'READ' | 'WRITE' // set when isShared = 1
}
export interface LocalYacht {
+10 -187
View File
@@ -3,14 +3,13 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { parseTrackFile } from './trackUpload.js'
import { syncLogbook } from './sync.js'
import { computeTrackStats } from '../utils/trackStats.js'
import i18n from '../i18n/index.js'
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
import dampSchleimuendeGpx from '../assets/demo/damp-schleimuende.gpx?raw'
import {
buildDemoCrewRecords,
buildDemoEntryPayloads,
buildDemoYachtData
} from './demoLogbookData.js'
export const SEED_DEMO_FLAG = 'seed_demo_logbook'
@@ -22,120 +21,6 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
return `demo_first_entry_id_${userId}`
}
interface DemoDaySpec {
date: string
dayOfTravel: string
departure: string
destination: string
gpx: string
filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
events: Array<Record<string, string>>
}
function buildDemoDays(): DemoDaySpec[] {
const isDe = i18n.language.startsWith('de')
return [
{
date: '2026-05-29',
dayOfTravel: '1',
departure: isDe ? 'Kiel' : 'Kiel',
destination: isDe ? 'Laboe' : 'Laboe',
gpx: kielLaboeGpx,
filename: 'kiel-laboe.gpx',
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
events: [
{
time: '10:15',
mgk: '042',
rwk: '038',
windDirection: isDe ? 'NW' : 'NW',
windStrength: '4 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Abfahrt Kiellinie' : 'Departure Kiellinie'
},
{
time: '11:20',
mgk: '030',
rwk: '028',
windDirection: 'N',
windStrength: '3 Bft',
seaState: isDe ? 'ruhig' : 'calm',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ankunft Laboe' : 'Arrival Laboe'
}
]
},
{
date: '2026-05-30',
dayOfTravel: '2',
departure: 'Laboe',
destination: 'Damp',
gpx: laboeDampGpx,
filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
events: [
{
time: '09:00',
mgk: '055',
rwk: '050',
windDirection: 'NE',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Auslaufen aus Laboe' : 'Departing Laboe'
},
{
time: '12:30',
mgk: '075',
rwk: '068',
windDirection: 'E',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Kurs entlang der Küste' : 'Coastal passage'
}
]
},
{
date: '2026-05-31',
dayOfTravel: '3',
departure: 'Damp',
destination: isDe ? 'Schleimünde' : 'Schleimünde',
gpx: dampSchleimuendeGpx,
filename: 'damp-schleimuende.gpx',
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
events: [
{
time: '08:30',
mgk: '290',
rwk: '285',
windDirection: 'W',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Passage zur Schlei' : 'Passage toward Schlei'
},
{
time: '14:00',
mgk: '310',
rwk: '305',
windDirection: 'NW',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ziel Schleimünde' : 'Destination Schleimünde'
}
]
}
]
}
async function putEncryptedRecord(
logbookId: string,
key: ArrayBuffer,
@@ -194,44 +79,12 @@ async function putEncryptedRecord(
}
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
const isDe = i18n.language.startsWith('de')
const yachtData = {
name: isDe ? 'Seeadler' : 'Seeadler',
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
lengthM: 12.5,
draftM: 1.9,
airDraftM: 18,
homePort: 'Kiel',
charterCompany: '',
owner: isDe ? 'Demo Skipper' : 'Demo Skipper',
registrationNumber: 'D-KI 1234',
callSign: 'DA1234',
atis: '',
mmsi: '',
sails: isDe
? ['Großsegel', 'Genua', 'Spinnaker']
: ['Mainsail', 'Genoa', 'Spinnaker'],
photo: null
}
const yachtData = buildDemoYachtData()
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
const crewId = crypto.randomUUID()
const crewData = {
name: isDe ? 'Anna Müller' : 'Anna Müller',
address: isDe ? 'Hafenstraße 1, 24103 Kiel' : 'Harbour St 1, 24103 Kiel',
birthDate: '1988-04-12',
phone: '+49 431 123456',
nationality: isDe ? 'Deutsch' : 'German',
passportNumber: 'C01X00T47',
bloodType: 'A+',
allergies: '',
diseases: '',
role: 'crew',
photo: null
for (const crew of buildDemoCrewRecords()) {
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
}
await putEncryptedRecord(logbookId, key, 'crew', crewId, crewData, now)
}
export interface DemoSeedResult {
@@ -273,42 +126,12 @@ export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null>
const now = new Date().toISOString()
await seedYachtAndCrew(logbookId, key, now)
const days = buildDemoDays()
const entryPayloads = buildDemoEntryPayloads()
let firstEntryId = ''
for (const day of days) {
const entryId = crypto.randomUUID()
for (const { entryId, entryPayload, trackData } of entryPayloads) {
if (!firstEntryId) firstEntryId = entryId
const { waypoints } = parseTrackFile(day.gpx, day.filename)
const stats = computeTrackStats(waypoints)
const entryPayload: Record<string, unknown> = {
date: day.date,
dayOfTravel: day.dayOfTravel,
departure: day.departure,
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
signSkipper: '',
signCrew: '',
events: day.events
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
await putEncryptedRecord(logbookId, key, 'entry', entryId, entryPayload, now)
const trackData = {
waypoints,
gpxContent: day.gpx,
filename: day.filename,
fileType: 'gpx'
}
await putEncryptedRecord(logbookId, key, 'gpsTrack', entryId, trackData, now)
}
+318
View File
@@ -0,0 +1,318 @@
import { parseTrackFile } from './trackUpload.js'
import { computeTrackStats } from '../utils/trackStats.js'
import i18n from '../i18n/index.js'
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
import dampSchleimuendeGpx from '../assets/demo/damp-schleimuende.gpx?raw'
/** Stable ID for the first demo travel day (public demo tour highlight). */
export const PUBLIC_DEMO_FIRST_ENTRY_ID = 'a0000001-0000-4000-8000-000000000001'
const PUBLIC_DEMO_ENTRY_IDS = [
PUBLIC_DEMO_FIRST_ENTRY_ID,
'a0000001-0000-4000-8000-000000000002',
'a0000001-0000-4000-8000-000000000003'
] as const
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
export interface DemoDaySpec {
date: string
dayOfTravel: string
departure: string
destination: string
gpx: string
filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
events: Array<Record<string, string>>
}
export interface DemoCrewRecord {
payloadId: string
data: {
name: string
address: string
birthDate: string
phone: string
nationality: string
passportNumber: string
bloodType: string
allergies: string
diseases: string
role: 'skipper' | 'crew'
photo: string | null
}
}
export interface PublicDemoFixture {
title: string
yacht: Record<string, unknown>
crews: DemoCrewRecord[]
entries: Array<Record<string, unknown> & { payloadId: string }>
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
photos: never[]
firstEntryId: string
}
export function buildDemoDays(): DemoDaySpec[] {
const isDe = i18n.language.startsWith('de')
return [
{
date: '2026-05-29',
dayOfTravel: '1',
departure: 'Kiel',
destination: 'Laboe',
gpx: kielLaboeGpx,
filename: 'kiel-laboe.gpx',
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
events: [
{
time: '10:15',
mgk: '042',
rwk: '038',
windDirection: 'NW',
windStrength: '4 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Abfahrt Kiellinie' : 'Departure Kiellinie'
},
{
time: '11:20',
mgk: '030',
rwk: '028',
windDirection: 'N',
windStrength: '3 Bft',
seaState: isDe ? 'ruhig' : 'calm',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ankunft Laboe' : 'Arrival Laboe'
}
]
},
{
date: '2026-05-30',
dayOfTravel: '2',
departure: 'Laboe',
destination: 'Damp',
gpx: laboeDampGpx,
filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
events: [
{
time: '09:00',
mgk: '055',
rwk: '050',
windDirection: 'NE',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Auslaufen aus Laboe' : 'Departing Laboe'
},
{
time: '12:30',
mgk: '075',
rwk: '068',
windDirection: 'E',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Kurs entlang der Küste' : 'Coastal passage'
}
]
},
{
date: '2026-05-31',
dayOfTravel: '3',
departure: 'Damp',
destination: 'Schleimünde',
gpx: dampSchleimuendeGpx,
filename: 'damp-schleimuende.gpx',
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
events: [
{
time: '08:30',
mgk: '290',
rwk: '285',
windDirection: 'W',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Passage zur Schlei' : 'Passage toward Schlei'
},
{
time: '14:00',
mgk: '310',
rwk: '305',
windDirection: 'NW',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ziel Schleimünde' : 'Destination Schleimünde'
}
]
}
]
}
export function buildDemoYachtData(): Record<string, unknown> {
const isDe = i18n.language.startsWith('de')
return {
name: 'Seeadler',
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
lengthM: 12.5,
draftM: 1.9,
airDraftM: 18,
homePort: 'Kiel',
charterCompany: '',
owner: 'Demo Skipper',
registrationNumber: 'D-KI 1234',
callSign: 'DA1234',
atis: '',
mmsi: '',
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
photo: null
}
}
export function buildDemoCrewRecords(): DemoCrewRecord[] {
const isDe = i18n.language.startsWith('de')
return [
{
payloadId: 'skipper',
data: {
name: 'Demo Skipper',
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
birthDate: '1980-06-15',
phone: '+49 431 987654',
nationality: isDe ? 'Deutsch' : 'German',
passportNumber: 'C12X34Y56',
bloodType: '0+',
allergies: '',
diseases: '',
role: 'skipper',
photo: null
}
},
{
payloadId: PUBLIC_DEMO_CREW_MEMBER_ID,
data: {
name: 'Anna Müller',
address: isDe ? 'Hafenstraße 1, 24103 Kiel' : 'Harbour St 1, 24103 Kiel',
birthDate: '1988-04-12',
phone: '+49 431 123456',
nationality: isDe ? 'Deutsch' : 'German',
passportNumber: 'C01X00T47',
bloodType: 'A+',
allergies: '',
diseases: '',
role: 'crew',
photo: null
}
}
]
}
export function buildPublicDemoFixture(): PublicDemoFixture {
const title = i18n.t('demo.logbook_title')
const yacht = buildDemoYachtData()
const crews = buildDemoCrewRecords()
const days = buildDemoDays()
const entries: PublicDemoFixture['entries'] = []
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
days.forEach((day, index) => {
const entryId = PUBLIC_DEMO_ENTRY_IDS[index] ?? crypto.randomUUID()
const { waypoints } = parseTrackFile(day.gpx, day.filename)
const stats = computeTrackStats(waypoints)
const entryPayload: Record<string, unknown> = {
payloadId: entryId,
date: day.date,
dayOfTravel: day.dayOfTravel,
departure: day.departure,
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
signSkipper: '',
signCrew: '',
events: day.events
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
entries.push(entryPayload as PublicDemoFixture['entries'][number])
gpsTracks.push({
entryId,
waypoints,
filename: day.filename,
gpxContent: day.gpx,
fileType: 'gpx'
})
})
return {
title,
yacht,
crews,
entries,
gpsTracks,
photos: [],
firstEntryId: PUBLIC_DEMO_FIRST_ENTRY_ID
}
}
export function getPublicDemoFirstEntryId(): string {
return PUBLIC_DEMO_FIRST_ENTRY_ID
}
/** Payloads for encrypted seeding (without payloadId on entries). */
export function buildDemoEntryPayloads(): Array<{
entryId: string
entryPayload: Record<string, unknown>
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
}> {
const days = buildDemoDays()
return days.map((day) => {
const entryId = crypto.randomUUID()
const { waypoints } = parseTrackFile(day.gpx, day.filename)
const stats = computeTrackStats(waypoints)
const entryPayload: Record<string, unknown> = {
date: day.date,
dayOfTravel: day.dayOfTravel,
departure: day.departure,
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
signSkipper: '',
signCrew: '',
events: day.events
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
return {
entryId,
entryPayload,
trackData: {
waypoints,
gpxContent: day.gpx,
filename: day.filename,
fileType: 'gpx'
}
}
})
}
+40 -10
View File
@@ -6,12 +6,31 @@ import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
const API_BASE = '/api/logbooks'
export type LogbookAccessRole = 'OWNER' | 'READ' | 'WRITE'
export type CollaborationRole = 'READ' | 'WRITE'
/** Validates server/cached collaboration role; warns and falls back to WRITE if missing or invalid. */
export function parseCollaborationRole(role: unknown, context: string): CollaborationRole {
if (role === 'READ' || role === 'WRITE') {
return role
}
if (role === undefined || role === null || role === '') {
console.warn(`[collaboration] Missing role in ${context}; defaulting to WRITE.`)
} else {
console.warn(`[collaboration] Unexpected role in ${context}:`, role, '— defaulting to WRITE.')
}
return 'WRITE'
}
export interface DecryptedLogbook {
id: string
title: string
updatedAt: string
isSynced: boolean
isShared: boolean
accessRole: LogbookAccessRole
isDemo?: boolean
}
@@ -101,14 +120,20 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
// Update Dexie database cache
const localById = new Map(localLogbooksArray.map((lb) => [lb.id, lb]))
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({
id: lb.id,
encryptedTitle: lb.encryptedTitle,
updatedAt: lb.updatedAt || new Date().toISOString(),
isSynced: 1,
isShared: lb.userId !== userId ? 1 : 0,
isDemo: localById.get(lb.id)?.isDemo
}))
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => {
const isShared = lb.userId !== userId
return {
id: lb.id,
encryptedTitle: lb.encryptedTitle,
updatedAt: lb.updatedAt || new Date().toISOString(),
isSynced: 1,
isShared: isShared ? 1 : 0,
collaborationRole: isShared
? parseCollaborationRole(lb.collaborators?.[0]?.role, `fetch logbook ${lb.id}`)
: undefined,
isDemo: localById.get(lb.id)?.isDemo
}
})
// Clear existing cache for this user and insert new ones
await db.logbooks.bulkPut(localLogbooks)
@@ -131,6 +156,9 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
updatedAt: lb.updatedAt,
isSynced: lb.isSynced === 1,
isShared: lb.isShared === 1,
accessRole: lb.isShared === 1
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
: 'OWNER',
isDemo: lb.isDemo === 1
})
}
@@ -207,7 +235,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
title,
updatedAt: serverLb.updatedAt,
isSynced: true,
isShared: false
isShared: false,
accessRole: 'OWNER'
}
}
} catch (error) {
@@ -238,7 +267,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
title,
updatedAt: now,
isSynced: false,
isShared: false
isShared: false,
accessRole: 'OWNER'
}
}
+601
View File
@@ -0,0 +1,601 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import {
decryptJson,
encryptBuffer,
decryptBuffer
} from './crypto.js'
import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
import { syncLogbook } from './sync.js'
import type { SyncQueueItem } from './db.js'
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
export const BACKUP_VERSION = 1 as const
export interface LogbookBackupFile {
format: typeof BACKUP_FORMAT
version: typeof BACKUP_VERSION
exportedAt: string
logbook: {
id: string
encryptedTitle: string
updatedAt: string
isDemo?: boolean
}
logbookKey: {
ciphertext: string
iv: string
tag: string
}
payloads: {
yacht: {
encryptedData: string
iv: string
tag: string
updatedAt: string
} | null
deviation: {
encryptedData: string
iv: string
tag: string
updatedAt: string
} | null
crews: Array<{
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
entries: Array<{
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
photos: Array<{
payloadId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
gpsTracks: Array<{
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
}
counts: {
entries: number
photos: number
crews: number
gpsTracks: number
hasYacht: boolean
hasDeviation: boolean
}
}
export interface LogbookBackupPreview {
title: string
exportedAt: string
sourceLogbookId: string
counts: LogbookBackupFile['counts']
}
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
const encoder = new TextEncoder()
const passphraseBytes = encoder.encode(passphrase.trim())
const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1')
const baseKey = await window.crypto.subtle.importKey(
'raw',
passphraseBytes,
{ name: 'PBKDF2' },
false,
['deriveKey']
)
return window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: saltBytes,
iterations: 100_000,
hash: 'SHA-256'
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
}
async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
const key = await deriveBackupPassphraseKey(passphrase)
return encryptBuffer(logbookKey, key)
}
async function unwrapLogbookKey(
wrapped: LogbookBackupFile['logbookKey'],
passphrase: string
): Promise<ArrayBuffer> {
const key = await deriveBackupPassphraseKey(passphrase)
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
}
function isBackupFile(value: unknown): value is LogbookBackupFile {
if (!value || typeof value !== 'object') return false
const obj = value as Partial<LogbookBackupFile>
return (
obj.format === BACKUP_FORMAT &&
obj.version === BACKUP_VERSION &&
typeof obj.exportedAt === 'string' &&
!!obj.logbook?.id &&
!!obj.logbook?.encryptedTitle &&
!!obj.logbookKey?.ciphertext &&
!!obj.payloads
)
}
function encryptedPayloadData(
encryptedData: string,
iv: string,
tag: string,
extra?: Record<string, string>
): string {
return JSON.stringify({
ciphertext: encryptedData,
iv,
tag,
...extra
})
}
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([
db.yachts.get(logbookId),
db.deviations.get(logbookId),
db.crews.where({ logbookId }).toArray(),
db.entries.where({ logbookId }).toArray(),
db.photos.where({ logbookId }).toArray(),
db.gpsTracks.where({ logbookId }).toArray()
])
return {
yacht: yacht
? {
encryptedData: yacht.encryptedData,
iv: yacht.iv,
tag: yacht.tag,
updatedAt: yacht.updatedAt
}
: null,
deviation: deviation
? {
encryptedData: deviation.encryptedData,
iv: deviation.iv,
tag: deviation.tag,
updatedAt: deviation.updatedAt
}
: null,
crews: crews.map((c) => ({
payloadId: c.payloadId,
encryptedData: c.encryptedData,
iv: c.iv,
tag: c.tag,
updatedAt: c.updatedAt
})),
entries: entries.map((e) => ({
payloadId: e.payloadId,
encryptedData: e.encryptedData,
iv: e.iv,
tag: e.tag,
updatedAt: e.updatedAt
})),
photos: photos.map((p) => ({
payloadId: p.payloadId,
entryId: p.entryId,
encryptedData: p.encryptedData,
iv: p.iv,
tag: p.tag,
updatedAt: p.updatedAt
})),
gpsTracks: gpsTracks.map((t) => ({
entryId: t.entryId,
encryptedData: t.encryptedData,
iv: t.iv,
tag: t.tag,
updatedAt: t.updatedAt
}))
}
}
function remapBackup(
backup: LogbookBackupFile,
newLogbookId: string
): LogbookBackupFile {
return {
...backup,
logbook: {
...backup.logbook,
id: newLogbookId
},
payloads: {
...backup.payloads,
yacht: backup.payloads.yacht
? { ...backup.payloads.yacht, updatedAt: backup.payloads.yacht.updatedAt }
: null,
deviation: backup.payloads.deviation
? { ...backup.payloads.deviation, updatedAt: backup.payloads.deviation.updatedAt }
: null,
crews: backup.payloads.crews.map((c) => ({ ...c })),
entries: backup.payloads.entries.map((e) => ({ ...e })),
photos: backup.payloads.photos.map((p) => ({ ...p })),
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
}
}
}
async function queueRestoredLogbookForSync(
logbookId: string,
encryptedTitle: string,
logbookKey: ArrayBuffer,
payloads: LogbookBackupFile['payloads']
): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found')
const aesMasterKey = await window.crypto.subtle.importKey(
'raw',
masterKey,
{ name: 'AES-GCM' },
false,
['encrypt']
)
const encryptedKey = await encryptBuffer(logbookKey, aesMasterKey)
const now = new Date().toISOString()
const items: Omit<SyncQueueItem, 'id'>[] = [
{
action: 'create',
type: 'logbook',
payloadId: logbookId,
logbookId,
data: JSON.stringify({
encryptedTitle,
encryptedKey: encryptedKey.ciphertext,
iv: encryptedKey.iv,
tag: encryptedKey.tag
}),
updatedAt: now
}
]
if (payloads.yacht) {
items.push({
action: 'update',
type: 'yacht',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(
payloads.yacht.encryptedData,
payloads.yacht.iv,
payloads.yacht.tag
),
updatedAt: payloads.yacht.updatedAt
})
}
if (payloads.deviation) {
items.push({
action: 'update',
type: 'deviation',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(
payloads.deviation.encryptedData,
payloads.deviation.iv,
payloads.deviation.tag
),
updatedAt: payloads.deviation.updatedAt
})
}
for (const crew of payloads.crews) {
items.push({
action: 'create',
type: 'crew',
payloadId: crew.payloadId,
logbookId,
data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag),
updatedAt: crew.updatedAt
})
}
for (const entry of payloads.entries) {
items.push({
action: 'create',
type: 'entry',
payloadId: entry.payloadId,
logbookId,
data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag),
updatedAt: entry.updatedAt
})
}
for (const photo of payloads.photos) {
items.push({
action: 'create',
type: 'photo',
payloadId: photo.payloadId,
logbookId,
data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, {
entryId: photo.entryId
}),
updatedAt: photo.updatedAt
})
}
for (const track of payloads.gpsTracks) {
items.push({
action: 'create',
type: 'gpsTrack',
payloadId: track.entryId,
logbookId,
data: encryptedPayloadData(track.encryptedData, track.iv, track.tag),
updatedAt: track.updatedAt
})
}
await db.syncQueue.bulkPut(items)
}
async function writeBackupToDexie(
logbookId: string,
backup: LogbookBackupFile,
logbookKey: ArrayBuffer
): Promise<void> {
const { logbook, payloads } = backup
await db.logbooks.put({
id: logbookId,
encryptedTitle: logbook.encryptedTitle,
updatedAt: logbook.updatedAt,
isSynced: 0,
isShared: 0,
isDemo: logbook.isDemo ? 1 : 0
})
await saveLogbookKey(logbookId, logbookKey)
if (payloads.yacht) {
await db.yachts.put({
logbookId,
encryptedData: payloads.yacht.encryptedData,
iv: payloads.yacht.iv,
tag: payloads.yacht.tag,
updatedAt: payloads.yacht.updatedAt
})
}
if (payloads.deviation) {
await db.deviations.put({
logbookId,
encryptedData: payloads.deviation.encryptedData,
iv: payloads.deviation.iv,
tag: payloads.deviation.tag,
updatedAt: payloads.deviation.updatedAt
})
}
if (payloads.crews.length > 0) {
await db.crews.bulkPut(
payloads.crews.map((c) => ({
payloadId: c.payloadId,
logbookId,
encryptedData: c.encryptedData,
iv: c.iv,
tag: c.tag,
updatedAt: c.updatedAt
}))
)
}
if (payloads.entries.length > 0) {
await db.entries.bulkPut(
payloads.entries.map((e) => ({
payloadId: e.payloadId,
logbookId,
encryptedData: e.encryptedData,
iv: e.iv,
tag: e.tag,
updatedAt: e.updatedAt
}))
)
}
if (payloads.photos.length > 0) {
await db.photos.bulkPut(
payloads.photos.map((p) => ({
payloadId: p.payloadId,
entryId: p.entryId,
logbookId,
encryptedData: p.encryptedData,
iv: p.iv,
tag: p.tag,
caption: '',
updatedAt: p.updatedAt
}))
)
}
if (payloads.gpsTracks.length > 0) {
await db.gpsTracks.bulkPut(
payloads.gpsTracks.map((t) => ({
entryId: t.entryId,
logbookId,
encryptedData: t.encryptedData,
iv: t.iv,
tag: t.tag,
updatedAt: t.updatedAt
}))
)
}
}
export async function exportLogbookBackup(
logbookId: string,
passphrase: string
): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> {
if (!passphrase.trim() || passphrase.length < 8) {
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
}
const logbook = await db.logbooks.get(logbookId)
if (!logbook || logbook.isShared === 1) {
throw new Error('BACKUP_NOT_OWNER')
}
if (navigator.onLine) {
await syncLogbook(logbookId).catch((err) => {
console.warn('Pre-backup sync failed, exporting local data:', err)
})
}
const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
const payloads = await collectLogbookPayloads(logbookId)
const wrappedKey = await wrapLogbookKey(logbookKey, passphrase)
const backup: LogbookBackupFile = {
format: BACKUP_FORMAT,
version: BACKUP_VERSION,
exportedAt: new Date().toISOString(),
logbook: {
id: logbook.id,
encryptedTitle: logbook.encryptedTitle,
updatedAt: logbook.updatedAt,
isDemo: logbook.isDemo === 1
},
logbookKey: wrappedKey,
payloads,
counts: {
entries: payloads.entries.length,
photos: payloads.photos.length,
crews: payloads.crews.length,
gpsTracks: payloads.gpsTracks.length,
hasYacht: !!payloads.yacht,
hasDeviation: !!payloads.deviation
}
}
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
const datePart = new Date().toISOString().slice(0, 10)
const filename = `${safeTitle}-${datePart}.daagbok.json`
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
return { blob, filename, backup }
}
export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupFile> {
const text = await file.text()
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new Error('BACKUP_INVALID_JSON')
}
if (!isBackupFile(parsed)) {
throw new Error('BACKUP_INVALID_FORMAT')
}
return parsed
}
export async function previewLogbookBackup(
backup: LogbookBackupFile,
passphrase: string
): Promise<LogbookBackupPreview> {
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
const parsed = JSON.parse(backup.logbook.encryptedTitle)
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
return {
title,
exportedAt: backup.exportedAt,
sourceLogbookId: backup.logbook.id,
counts: backup.counts
}
}
export interface RestoreLogbookOptions {
overwrite?: boolean
assignNewId?: boolean
}
export async function restoreLogbookBackup(
backup: LogbookBackupFile,
passphrase: string,
options: RestoreLogbookOptions = {}
): Promise<{ logbookId: string; title: string }> {
if (!getActiveMasterKey()) {
throw new Error('BACKUP_NOT_AUTHENTICATED')
}
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
const parsedTitle = JSON.parse(backup.logbook.encryptedTitle)
const title = await decryptJson(
parsedTitle.ciphertext,
parsedTitle.iv,
parsedTitle.tag,
logbookKey
)
let targetId = backup.logbook.id
const existing = await db.logbooks.get(targetId)
if (existing && !options.overwrite && !options.assignNewId) {
throw new Error('BACKUP_ID_CONFLICT')
}
if (existing && options.overwrite) {
await deleteLocalLogbookCache(targetId)
}
if (options.assignNewId || (existing && !options.overwrite)) {
targetId = crypto.randomUUID()
}
const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId)
await writeBackupToDexie(targetId, prepared, logbookKey)
await queueRestoredLogbookForSync(
targetId,
prepared.logbook.encryptedTitle,
logbookKey,
prepared.payloads
)
if (navigator.onLine) {
await syncLogbook(targetId).catch((err) => {
console.warn('Post-restore sync failed, data saved locally:', err)
})
}
return { logbookId: targetId, title }
}
export function downloadBackupBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
+182
View File
@@ -0,0 +1,182 @@
const API_BASE = '/api/push'
function getUserId(): string | null {
return localStorage.getItem('active_userid')
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(base64)
const output = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) {
output[i] = raw.charCodeAt(i)
}
return output
}
export function isPushSupported(): boolean {
return (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
)
}
export function getNotificationPermission(): NotificationPermission | 'unsupported' {
if (!isPushSupported()) return 'unsupported'
return Notification.permission
}
async function fetchVapidPublicKey(): Promise<string | null> {
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
if (typeof envKey === 'string' && envKey.trim()) {
return envKey.trim()
}
try {
const res = await fetch(`${API_BASE}/vapid-public-key`)
if (!res.ok) return null
const data = await res.json()
return typeof data.publicKey === 'string' ? data.publicKey : null
} catch {
return null
}
}
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
const userId = getUserId()
if (!userId) return { collaboratorChangesEnabled: false }
const res = await fetch(`${API_BASE}/prefs`, {
headers: { 'X-User-Id': userId }
})
if (!res.ok) {
throw new Error('Failed to load push notification preferences')
}
return res.json()
}
export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promise<void> {
const userId = getUserId()
if (!userId) throw new Error('Not authenticated')
const res = await fetch(`${API_BASE}/prefs`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ collaboratorChangesEnabled })
})
if (!res.ok) {
throw new Error('Failed to save push notification preferences')
}
}
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
const userId = getUserId()
if (!userId) throw new Error('Not authenticated')
const json = subscription.toJSON()
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
throw new Error('Invalid push subscription')
}
const locale = document.documentElement.lang?.startsWith('en') ? 'en' : 'de'
const res = await fetch(`${API_BASE}/subscription`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({
endpoint: json.endpoint,
keys: json.keys,
locale,
userAgent: navigator.userAgent
})
})
if (!res.ok) {
throw new Error('Failed to register push subscription on server')
}
}
export async function subscribeToPush(): Promise<void> {
if (!isPushSupported()) {
throw new Error('Push notifications are not supported on this device')
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
throw new Error('Notification permission denied')
}
const publicKey = await fetchVapidPublicKey()
if (!publicKey) {
throw new Error('Push notifications are not configured on this server')
}
const registration = await navigator.serviceWorker.ready
let subscription = await registration.pushManager.getSubscription()
if (!subscription) {
const keyBytes = urlBase64ToUint8Array(publicKey)
const applicationServerKey = new Uint8Array(keyBytes)
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
})
}
await saveSubscriptionToServer(subscription)
}
export async function unsubscribeFromPush(): Promise<void> {
if (!isPushSupported()) return
const userId = getUserId()
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (!subscription) return
const endpoint = subscription.endpoint
await subscription.unsubscribe()
if (userId && endpoint) {
await fetch(`${API_BASE}/subscription`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ endpoint })
}).catch(() => {})
}
}
/** Re-register subscription when prefs are on and permission already granted. */
export async function ensurePushSubscriptionIfEnabled(): Promise<void> {
if (!isPushSupported() || Notification.permission !== 'granted') return
const prefs = await fetchPushPrefs()
if (!prefs.collaboratorChangesEnabled) return
try {
await subscribeToPush()
} catch (err) {
console.warn('Could not refresh push subscription:', err)
}
}
export async function enableCollaboratorChangePush(): Promise<void> {
await subscribeToPush()
await savePushPrefs(true)
}
export async function disableCollaboratorChangePush(): Promise<void> {
await savePushPrefs(false)
await unsubscribeFromPush()
}
+68 -10
View File
@@ -32,7 +32,48 @@ function entityKey(item: SyncQueueItem): string {
return `${item.type}:${item.payloadId}`
}
// Keep only the latest queue entry per entity; delete wins over create/update.
function latestQueueItem(items: SyncQueueItem[]): SyncQueueItem {
return items.reduce((a, b) => ((a.id ?? 0) > (b.id ?? 0) ? a : b))
}
async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
switch (item.type) {
case 'logbook':
return !!(await db.logbooks.get(item.payloadId))
case 'yacht':
return !!(await db.yachts.get(item.logbookId))
case 'deviation':
return !!(await db.deviations.get(item.logbookId))
case 'crew':
return !!(await db.crews.get(item.payloadId))
case 'entry':
return !!(await db.entries.get(item.payloadId))
case 'photo':
return !!(await db.photos.get(item.payloadId))
case 'gpsTrack':
return !!(await db.gpsTracks.get(item.payloadId))
default:
return false
}
}
// Pick one queue entry per entity. If the record still exists locally, the latest
// action wins (supports recreate-after-delete). If it was removed locally, a delete
// wins over stale upserts with higher IDs; orphaned upserts are dropped entirely.
async function resolveCoalescedItem(group: SyncQueueItem[]): Promise<SyncQueueItem | null> {
const exists = await entityExistsLocally(group[0])
if (exists) {
return latestQueueItem(group)
}
const deletes = group.filter((item) => item.action === 'delete')
if (deletes.length > 0) {
return latestQueueItem(deletes)
}
return null
}
async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
const pending = await db.syncQueue.where({ logbookId }).toArray()
if (pending.length <= 1) return pending
@@ -49,16 +90,20 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
const staleIds: number[] = []
for (const group of byEntity.values()) {
const deletes = group.filter((item) => item.action === 'delete')
const latest =
deletes.length > 0
? deletes.reduce((a, b) => ((a.id ?? 0) > (b.id ?? 0) ? a : b))
: group.reduce((a, b) => ((a.id ?? 0) > (b.id ?? 0) ? a : b))
const winner = await resolveCoalescedItem(group)
kept.push(latest)
for (const item of group) {
if (item.id !== undefined && item.id !== latest.id) {
staleIds.push(item.id)
if (winner) {
kept.push(winner)
for (const item of group) {
if (item.id !== undefined && item.id !== winner.id) {
staleIds.push(item.id)
}
}
} else {
for (const item of group) {
if (item.id !== undefined) {
staleIds.push(item.id)
}
}
}
}
@@ -369,6 +414,19 @@ export async function syncAllLogbooks(): Promise<void> {
for (const lb of logbooks) {
await syncLogbook(lb.id)
}
// 3. Clean up orphaned queue items for logbooks no longer in db.logbooks.
// Re-read logbooks so any logbooks created during step 2 are included.
const freshLogbooks = await db.logbooks.toArray()
const freshKnownIds = new Set(freshLogbooks.map((l) => l.id))
const currentQueue = await db.syncQueue.toArray()
const orphanedIds = currentQueue
.filter((i) => !freshKnownIds.has(i.logbookId))
.map((i) => i.id!)
.filter(Boolean)
if (orphanedIds.length > 0) {
await db.syncQueue.bulkDelete(orphanedIds)
}
} catch (error) {
console.error('Error synchronizing all logbooks:', error)
} finally {
+73
View File
@@ -0,0 +1,73 @@
/// <reference lib="webworker" />
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
declare let self: ServiceWorkerGlobalScope
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
interface PushPayload {
title?: string
body?: string
tag?: string
renotify?: boolean
data?: {
url?: string
logbookId?: string
changeCount?: number
}
}
self.addEventListener('push', (event) => {
event.waitUntil(
(async () => {
let payload: PushPayload = {}
try {
payload = event.data?.json() ?? {}
} catch {
payload = { body: event.data?.text() ?? '' }
}
const title = payload.title ?? 'Kapteins Daagbok'
const body = payload.body ?? ''
const data = payload.data ?? {}
await self.registration.showNotification(title, {
body,
tag: payload.tag,
icon: '/logo.png',
badge: '/logo.png',
data
})
})()
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const data = (event.notification.data ?? {}) as PushPayload['data']
const targetPath = data?.url ?? '/'
const targetUrl = new URL(targetPath, self.location.origin).href
event.waitUntil(
(async () => {
const windowClients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true
})
for (const client of windowClients) {
if ('focus' in client) {
await client.focus()
client.postMessage({
type: 'OPEN_LOGBOOK',
logbookId: data?.logbookId
})
return
}
}
await self.clients.openWindow(targetUrl)
})()
)
})
+22
View File
@@ -1,5 +1,8 @@
import { hashEntryForSigning } from './entryCanonicalHash.js'
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
export type SkipperSignStatus = 'none' | 'valid' | 'invalid'
export function isSignatureImage(value: string | undefined | null): boolean {
return typeof value === 'string' && value.startsWith('data:image/')
}
@@ -31,6 +34,16 @@ export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: strin
return sig.entryHash === entryHash
}
export async function getSkipperSignStatus(
entry: Record<string, unknown>
): Promise<SkipperSignStatus> {
const signSkipper = normalizeSignature(entry.signSkipper)
if (!signSkipper) return 'none'
if (!isPasskeySignature(signSkipper)) return 'valid'
const hash = await hashEntryForSigning(entry)
return isSignatureValidForEntry(signSkipper, hash) ? 'valid' : 'invalid'
}
export interface SignatureExportLabels {
imagePlaceholder: string
passkeyLabel: (username: string, signedAt: string) => string
@@ -55,3 +68,12 @@ export function serializeSignature(value: SignatureValue | '' | undefined): Sign
const trimmed = value.trim()
return trimmed || undefined
}
/** Normalize then serialize — canonical form for persistence and dirty-check fingerprints. */
export function normalizedSerializedSignature(value: unknown): SignatureValue | undefined {
return serializeSignature(normalizeSignature(value) || '')
}
export function fingerprintSignature(value: unknown): SignatureValue | '' {
return normalizedSerializedSignature(value) ?? ''
}
+9
View File
@@ -1,5 +1,14 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />
/// <reference types="vite-plugin-pwa/client" />
interface ImportMetaEnv {
readonly VITE_VAPID_PUBLIC_KEY?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module '*?raw' {
const content: string
+4 -2
View File
@@ -38,10 +38,12 @@ export default defineConfig({
plugins: [
react(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
registerType: 'prompt',
includeAssets: ['favicon.ico', 'logo.png'],
workbox: {
cleanupOutdatedCaches: true,
injectManifest: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}']
},
manifest: {
+8 -2
View File
@@ -24,14 +24,19 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — |
| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
| Onboarding Tour Completed | Onboarding-Tour bis zum letzten Schritt durchlaufen (`AppTourContext.tsx`) | |
| Onboarding Tour Skipped | Tour vorzeitig beendet (Skip, Escape, Backdrop, `stopTour`) | `step`: Tour-Schritt-ID (z.B. `welcome`, `entry_track`) |
| Onboarding Tour Completed | Onboarding-Tour bis zum letzten Schritt durchlaufen (`AppTourContext.tsx`) | `mode`: `demo` (optional, bei Public-Demo) |
| Onboarding Tour Skipped | Tour vorzeitig beendet (Skip, Escape, Backdrop, `stopTour`) | `step`: Tour-Schritt-ID (z.B. `welcome`, `entry_track`), optional `mode`: `demo` |
| Demo Opened | Public-Demo unter `/demo` geöffnet (`DemoViewer.tsx`) | — |
| Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — |
| Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — |
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
## Bewusst nicht getrackt
@@ -47,6 +52,7 @@ Empfohlene Goal-Ketten für Auswertung:
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
3. **Kollaboration:** Invite Generated → Invite Accepted
4. **Export:** Travel Day Saved → PDF Exported / CSV Exported
5. **Datensicherung:** Backup Exported → Backup Restored
## Entwicklung
+417
View File
@@ -0,0 +1,417 @@
# Implementierungsplan: Push-Benachrichtigungen für Logbuch-Owner
**Ziel:** Der Owner eines Logbuchs soll per Web Push informiert werden, wenn ein eingeladenes Crewmitglied (Collaborator mit WRITE) Änderungen synchronisiert — auch wenn die App geschlossen ist.
**Stand Codebase:** Service Worker nur für PWA-Caching/Updates (`vite-plugin-pwa`). Sync läuft per `setInterval` im Tab (~30 s). Kein `web-push`, keine Push-Subscriptions in der DB.
---
## 1. Anforderungen
### Funktional (MVP)
| ID | Anforderung |
|----|-------------|
| N-01 | Owner kann Push-Benachrichtigungen global aktivieren/deaktivieren (Opt-in). |
| N-02 | Bei erfolgreichem Sync-Push durch einen **Nicht-Owner-Collaborator** erhält der Owner **eine** zusammengefasste Benachrichtigung pro Logbuch und Request (nicht pro Queue-Item). |
| N-03 | Klick auf die Benachrichtigung öffnet die App auf dem betroffenen Logbuch (Deep-Link `/logbook/:id` o. ä.). |
| N-04 | Benachrichtigungstext ist **generisch** (Zero-Knowledge: Server kann Titel/Inhalt nicht lesen). |
| N-05 | DE/EN über i18n-Keys; Sprache aus Browser/`Accept-Language` oder gespeicherter App-Sprache in Subscription-Metadaten. |
| N-06 | Abgelaufene/ungültige Subscriptions werden beim Fehlerversand gelöscht (410 Gone). |
### Nicht im MVP (später)
- Push an Collaborators bei Owner-Änderungen (bidirektional).
- Pro-Logbuch Ein/Aus (nur global reicht zunächst).
- Inhaltliche Details („Eintrag #3 bearbeitet“) — würde Klartext auf dem Server erfordern.
- E-Mail/SMS als Fallback.
- „Quiet hours“ / Do-not-disturb-Zeiten.
### Akzeptanzkriterien (UAT)
1. Owner aktiviert Push in den Einstellungen → Browser fragt Berechtigung → Subscription liegt in DB.
2. Collaborator bearbeitet Eintrag, App des Collaborators synct → Owner erhält Push innerhalb weniger Sekunden (Gerät online, Berechtigung erteilt).
3. Owner mit deaktivierten Push-Einstellungen erhält nichts.
4. Bulk-Sync (10 Items) → genau **eine** Push-Nachricht.
5. Klick öffnet installierte PWA oder Browser-Tab mit korrektem Logbuch.
---
## 2. Architektur
```mermaid
sequenceDiagram
participant Crew as Crew-Client
participant API as Express API
participant DB as PostgreSQL
participant Push as web-push (VAPID)
participant SW as Service Worker (Owner)
participant Owner as Owner-Gerät
Crew->>API: POST /api/sync/push (X-User-Id: crew)
API->>DB: Payloads speichern
API->>API: collaborator change? → notify owner
API->>DB: PushSubscriptions (owner)
API->>Push: sendNotification (pro Endpoint)
Push->>SW: Push Event
SW->>Owner: System-Benachrichtigung
Owner->>SW: notificationclick
SW->>Owner: openWindow(/logbook/:id)
```
### Komponenten
| Schicht | Neu/Geändert | Aufgabe |
|---------|--------------|---------|
| **Prisma** | Neu | `PushSubscription`, optional `UserNotificationPrefs` |
| **Server** | Neu | `routes/push.ts`, `services/pushNotify.ts`, Env VAPID |
| **sync.ts** | Änderung | Nach erfolgreichem Collaborator-Push Owner benachrichtigen |
| **Client SW** | Neu | Custom SW (`injectManifest`) mit `push` + `notificationclick` |
| **Client UI** | Neu | Einstellungen: Toggle, Permission-Flow, Status |
| **Client Service** | Neu | `pushNotifications.ts` — subscribe, unsubscribe, sync mit API |
---
## 3. Plattform- und Produkt-Hinweise
| Thema | Auswirkung |
|-------|------------|
| **iOS** | Web Push für installierte PWAs ab **iOS 16.4+**. Nutzer müssen App zum Home Screen hinzufügen und Push erlauben. |
| **Android / Desktop** | Chrome/Edge/Firefox: gut unterstützt; PWA installiert empfohlen. |
| **HTTPS** | Web Push nur über HTTPS (Produktion erfüllt das). |
| **Zero-Knowledge** | Text z. B. „Neue Änderung in einem Ihrer Logbücher“ + `logbookId` nur im `data`-Payload (nicht im sichtbaren Titel nötig). |
| **Datenschutz** | Push-Endpoints sind personenbezogen → in Datenschutzerklärung erwähnen; Löschung bei Account-Löschung (Cascade). |
---
## 4. Datenmodell (Prisma)
```prisma
model PushSubscription {
id String @id @default(uuid())
userId String
endpoint String @unique
p256dh String // keys.p256dh (base64url)
auth String // keys.auth (base64url)
userAgent String? // optional, Debugging
locale String? // "de" | "en" — für Notification-Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model UserNotificationPrefs {
userId String @id
collaboratorChangesEnabled Boolean @default(false)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
```
`User`-Relationen ergänzen: `pushSubscriptions`, `notificationPrefs`.
**Migration:** `npx prisma migrate dev --name add_push_subscriptions`
---
## 5. Server-Implementierung
### 5.1 Abhängigkeit & Umgebung
```bash
npm install web-push --workspace=server
```
`.env` (Beispiel):
```env
VAPID_PUBLIC_KEY=...
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
```
Keys einmalig erzeugen:
```bash
npx web-push generate-vapid-keys
```
Öffentlichen Key zusätzlich als `VITE_VAPID_PUBLIC_KEY` für den Client (nur Public Key).
### 5.2 API-Routen (`/api/push`)
| Methode | Pfad | Auth | Beschreibung |
|---------|------|------|--------------|
| `GET` | `/vapid-public-key` | nein | Liefert Public Key für `pushManager.subscribe` |
| `PUT` | `/subscription` | `X-User-Id` | Upsert Subscription (endpoint + keys) |
| `DELETE` | `/subscription` | `X-User-Id` | Body: `{ endpoint }` — Gerät abmelden |
| `GET` | `/prefs` | `X-User-Id` | Liest `collaboratorChangesEnabled` |
| `PUT` | `/prefs` | `X-User-Id` | Body: `{ collaboratorChangesEnabled: boolean }` |
`requireUser`-Middleware wie in `sync.ts` / `collaboration.ts` wiederverwenden.
### 5.3 Benachrichtigungs-Service
**Datei:** `server/src/services/pushNotify.ts`
```ts
// Pseudocode — Kernlogik
export async function notifyOwnerOfCollaboratorChanges(
logbookId: string,
ownerUserId: string,
actorUserId: string,
changeCount: number
): Promise<void>
```
Ablauf:
1. `UserNotificationPrefs`: wenn `collaboratorChangesEnabled !== true` → return.
2. Alle `PushSubscription` für `ownerUserId` laden.
3. Payload (Web Push JSON):
```json
{
"title": "Kapteins Daagbok",
"body": "Neue Änderung in einem Ihrer Logbücher.",
"tag": "logbook-change-{logbookId}",
"renotify": false,
"data": { "url": "/logbook/{logbookId}", "logbookId": "{logbookId}", "changeCount": 3 }
}
```
4. `webpush.sendNotification(subscription, payload, options)` parallel mit `Promise.allSettled`.
5. Bei Status **410** oder **404**: Subscription aus DB löschen.
6. Fehler loggen, Sync-Response **nicht** fehlschlagen lassen (Push ist Best-Effort).
**Deduplizierung / Rate-Limit (empfohlen):**
- In-Memory-Map `ownerId:logbookId → lastSentAt` mit TTL 25 Minuten, **oder**
- Redis/DB-Tabelle `NotificationThrottle` mit `lastSentAt`.
Verhindert Push-Spam bei großen Offline-Queues.
### 5.4 Hook in `sync.ts`
Nach der Schleife über `items` (oder innerhalb, mit Sammellogik):
```ts
// Pro Request sammeln:
const ownerNotifications = new Map<string, { logbookId: string; count: number }>()
// Bei jedem erfolgreichen Item:
if (res.status === 'success' && !isOwner && isCollaborator) {
if (action === 'create' || action === 'update') {
const ownerId = logbook.userId
const key = `${ownerId}:${logbookId}`
const prev = ownerNotifications.get(key) ?? { logbookId, count: 0 }
prev.count++
ownerNotifications.set(key, prev)
}
}
// Nach der Schleife, async fire-and-forget:
for (const [key, { logbookId, count }] of ownerNotifications) {
const ownerId = key.split(':')[0]
void notifyOwnerOfCollaboratorChanges(logbookId, ownerId, req.userId, count)
}
```
**Wichtig:** Owner, der selbst als „Crew“ irrtümlich synct, ist `isOwner` — kein Push.
**Optional später:** auch `delete`-Aktionen einbeziehen (gleiche Logik).
### 5.5 `index.ts`
```ts
import pushRouter from './routes/push.js'
app.use('/api/push', pushRouter)
```
---
## 6. Client-Implementierung
### 6.1 Service Worker (Custom `injectManifest`)
`vite.config.ts` anpassen:
```ts
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectRegister: 'auto',
// manifest unverändert
})
```
**Datei:** `client/src/sw.ts`
- `precacheAndRoute` von Workbox importieren (wie vite-plugin-pwa-Doku).
- `self.addEventListener('push', …)`:
- `event.data.json()` parsen
- `self.registration.showNotification(title, { body, tag, data, icon: '/logo.png' })`
- `notificationclick`:
- `event.notification.close()`
- `clients.openWindow(data.url || '/')` — absolute URL mit `self.location.origin`
**i18n im SW:** MVP mit serverseitigem `locale` in Subscription; alternativ nur EN/DE-Body vom Server senden.
### 6.2 Client-Service `pushNotifications.ts`
| Funktion | Beschreibung |
|----------|--------------|
| `isPushSupported()` | `'serviceWorker' in navigator && 'PushManager' in window` |
| `getPermissionState()` | `Notification.permission` |
| `subscribeToPush()` | SW ready → `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })``PUT /api/push/subscription` |
| `unsubscribeFromPush()` | `subscription.unsubscribe()` + `DELETE` API |
| `syncPrefs(enabled)` | `PUT /api/push/prefs` |
| `ensureSubscriptionOnLogin()` | Wenn Prefs an und Permission granted, Subscription erneuern (Key-Rotation) |
`applicationServerKey`: VAPID Public Key von `GET /api/push/vapid-public-key` oder Build-Time `import.meta.env.VITE_VAPID_PUBLIC_KEY`.
### 6.3 UI (Settings)
**Ort:** `SettingsForm.tsx` (nur für Owner sichtbar, nicht bei `readOnly` / Crew-Logbuch).
Ablauf beim Einschalten:
1. `Notification.requestPermission()` — bei `denied` Hinweis + Link zu Browser-Einstellungen.
2. `subscribeToPush()` + `syncPrefs(true)`.
3. Bei Erfolg: grüner Status „Push aktiv“.
Beim Ausschalten:
1. `syncPrefs(false)` + optional `unsubscribeFromPush()` auf diesem Gerät.
**Hinweis-Banner** wenn `!isPushSupported()` oder iOS & nicht installiert → Verweis auf `PwaInstallPrompt`.
### 6.4 Deep-Link beim Öffnen
In `App.tsx` oder Router: beim Start `url` aus `notificationclick` via `clients.matchAll` nicht nötig — SW öffnet direkt.
Sicherstellen, dass Route `/logbook/:logbookId` (oder bestehende Logbuch-Route) existiert und Auth-Gate passiert.
### 6.5 Bestehenden SW-Update-Flow
`usePwaUpdate.ts` bleibt kompatibel mit `injectManifest`, sofern `virtual:pwa-register` weiter registriert wird — vite-plugin-pwa-Doku für `injectManifest` + React beachten.
---
## 7. Sicherheit
| Risiko | Maßnahme |
|--------|----------|
| Fremde subscriben mit fremder `userId` | Nur authentifizierte Requests (`X-User-Id` wie heute — langfristig Session/JWT erwägen). |
| Push an falschen User | `notifyOwner` nur mit `logbook.userId` aus DB, nie aus Client-Body. |
| Endpoint-Injection | `endpoint` muss HTTPS-URL sein; Länge begrenzen. |
| Spam durch Crew | Rate-Limit + nur `create`/`update` im MVP. |
| VAPID Private Key | Nur Server-Env, nie im Client. |
---
## 8. Implementierungsphasen
### Phase 1 — Infrastruktur (12 Tage)
- [ ] VAPID-Keys für Dev/Prod
- [ ] Prisma-Modelle + Migration
- [ ] `web-push` + `pushNotify.ts` + Unit-Test mit Mock-Subscription
- [ ] Routen `/api/push/*`
- [ ] `GET /vapid-public-key`
### Phase 2 — Service Worker (1 Tag)
- [ ] Umstellung auf `injectManifest` + `sw.ts`
- [ ] `push` / `notificationclick` Handler
- [ ] Manueller Test: `web-push` CLI oder kleines Admin-Skript sendet Test-Push
### Phase 3 — Trigger & Client-Anbindung (12 Tage)
- [ ] Hook in `sync.ts` mit Aggregation
- [ ] `pushNotifications.ts`
- [ ] Settings-UI + i18n (`de.json` / `en.json`)
- [ ] Plausible-Event optional: `push_enabled`, `push_denied`
### Phase 4 — Härtung (1 Tag)
- [ ] Rate-Limit / `tag`-basierte Ersetzung gleicher Logbuch-Pushes
- [ ] 410-Cleanup
- [ ] README + Datenschutz-Hinweis
- [ ] E2E-Manual-Testmatrix (iOS PWA, Android Chrome, Desktop)
### Phase 5 — Deployment
- [ ] Env-Variablen in Produktion (Docker/Hosting)
- [ ] Nginx: `sw.js` weiterhin `no-cache` (bereits in `nginx.conf`)
- [ ] Smoke-Test nach Deploy
**Geschätzter Gesamtaufwand:** 46 Entwicklertage für MVP.
---
## 9. Testplan
| # | Szenario | Erwartung |
|---|----------|-----------|
| T1 | Push nicht unterstützt (alter Browser) | UI zeigt „nicht verfügbar“, kein Fehler |
| T2 | Permission denied | Toggle aus, erklärender Hinweis |
| T3 | Owner aktiviert, Crew synct 1 Eintrag | 1 Push |
| T4 | Crew synct 5 Einträge in einem Request | 1 Push |
| T5 | Owner Prefs aus | Kein Push |
| T6 | Ungültige Subscription | 410 → DB-Eintrag weg, nächster Push an andere Geräte ok |
| T7 | notificationclick | App öffnet richtiges Logbuch |
| T8 | Owner ändert selbst | Kein Push an sich selbst |
**Dev-Test ohne zweites Gerät:** Zwei Browser-Profile (Owner + Crew), Crew-Einladung wie in Produktion.
---
## 10. Offene Entscheidungen (vor Start klären)
1. **Nur Owner oder auch andere Collaborators?** — MVP: nur Owner.
2. **Rate-Limit-Dauer:** 2 min vs. 5 min — Empfehlung: **3 min** pro Logbuch.
3. **Mehrere Geräte des Owners:** alle Subscriptions benachrichtigen — ja (Standard).
4. **Auth verbessern:** Push-Routen jetzt mit `X-User-Id` wie Rest der API; Roadmap-Item: echte Session.
---
## 11. Referenzen
- [web-push (npm)](https://www.npmjs.com/package/web-push)
- [MDN: Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
- [vite-plugin-pwa: injectManifest](https://vite-pwa-org.netlify.app/guide/inject-manifest.html)
- [Apple: Web Push for PWAs (iOS 16.4+)](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/)
---
## 12. Datei-Checkliste (neu/geändert)
```
server/
prisma/schema.prisma # PushSubscription, UserNotificationPrefs
prisma/migrations/.../
src/routes/push.ts # neu
src/services/pushNotify.ts # neu
src/routes/sync.ts # Hook notifyOwner
src/index.ts # Router mount
package.json # web-push
client/
src/sw.ts # neu (injectManifest)
vite.config.ts # strategies: injectManifest
src/services/pushNotifications.ts # neu
src/components/PushNotificationSettings.tsx # neu (optional)
src/components/SettingsForm.tsx # Integration
src/i18n/locales/de.json, en.json
.env.example # VITE_VAPID_PUBLIC_KEY
docs/
push-notifications-plan.md # dieses Dokument
README.md # Feature-Zeile + Env-Hinweis
```
+19 -3
View File
@@ -143,10 +143,26 @@ APP_VERSION="$6"
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
echo "Pulling latest changes from Git..."
git pull --tags
echo "Syncing repository from origin..."
CURRENT_BRANCH="$(git branch --show-current)"
if [ -z "$CURRENT_BRANCH" ]; then
echo "Error: Could not determine current Git branch."
exit 1
fi
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
echo "Warning: Local changes on deployment host will be discarded."
fi
git fetch --tags origin
if [ $? -ne 0 ]; then
echo "Error: Git pull failed."
echo "Error: Git fetch failed."
exit 1
fi
git reset --hard "origin/${CURRENT_BRANCH}"
if [ $? -ne 0 ]; then
echo "Error: Git reset to origin/${CURRENT_BRANCH} failed."
exit 1
fi
+155 -1
View File
@@ -13,12 +13,14 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"prisma": "^5.10.2"
"prisma": "^5.10.2",
"web-push": "^3.6.7"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.11.24",
"@types/web-push": "^3.6.4",
"tsx": "^4.7.1",
"typescript": "^5.3.3"
}
@@ -762,6 +764,16 @@
"@types/node": "*"
}
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -775,12 +787,33 @@
"node": ">= 0.6"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -795,6 +828,12 @@
"node": ">=12.0.0"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@@ -819,6 +858,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -973,6 +1018,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -1253,6 +1307,15 @@
"node": ">= 0.4"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1273,6 +1336,42 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/https-proxy-agent/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -1300,6 +1399,27 @@
"node": ">= 0.10"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1369,6 +1489,21 @@
"node": ">= 0.6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -1800,6 +1935,25 @@
"node": ">= 0.8"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+3 -1
View File
@@ -15,12 +15,14 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"prisma": "^5.10.2"
"prisma": "^5.10.2",
"web-push": "^3.6.7"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.11.24",
"@types/web-push": "^3.6.4",
"tsx": "^4.7.1",
"typescript": "^5.3.3"
}
+26
View File
@@ -20,6 +20,32 @@ model User {
credentials Credential[]
logbooks Logbook[]
collaborations Collaboration[]
pushSubscriptions PushSubscription[]
notificationPrefs UserNotificationPrefs?
}
model PushSubscription {
id String @id @default(uuid())
userId String
endpoint String @unique
p256dh String
auth String
userAgent String?
locale String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model UserNotificationPrefs {
userId String @id
collaboratorChangesEnabled Boolean @default(false)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Credential {
+2
View File
@@ -6,6 +6,7 @@ import logbooksRouter from './routes/logbooks.js'
import syncRouter from './routes/sync.js'
import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js'
import pushRouter from './routes/push.js'
import { prisma } from './db.js'
dotenv.config()
@@ -22,6 +23,7 @@ app.use('/api/logbooks', logbooksRouter)
app.use('/api/sync', syncRouter)
app.use('/api/collaboration', collaborationRouter)
app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter)
// Health check endpoint
app.get('/api/health', async (req, res) => {
+139
View File
@@ -0,0 +1,139 @@
import { Router } from 'express'
import { prisma } from '../db.js'
const router = Router()
const requireUser = (req: any, res: any, next: any) => {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
req.userId = userId
next()
}
function isValidHttpsEndpoint(endpoint: unknown): endpoint is string {
if (typeof endpoint !== 'string' || endpoint.length > 2048) return false
try {
const url = new URL(endpoint)
return url.protocol === 'https:'
} catch {
return false
}
}
router.get('/vapid-public-key', (_req, res) => {
const publicKey = process.env.VAPID_PUBLIC_KEY
if (!publicKey) {
return res.status(503).json({ error: 'Push notifications are not configured on this server' })
}
return res.json({ publicKey })
})
router.use(requireUser)
router.get('/prefs', async (req: any, res) => {
try {
const prefs = await prisma.userNotificationPrefs.findUnique({
where: { userId: req.userId }
})
return res.json({
collaboratorChangesEnabled: prefs?.collaboratorChangesEnabled ?? false
})
} catch (error: any) {
console.error('Error reading push prefs:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.put('/prefs', async (req: any, res) => {
try {
const { collaboratorChangesEnabled } = req.body
if (typeof collaboratorChangesEnabled !== 'boolean') {
return res.status(400).json({ error: 'collaboratorChangesEnabled must be a boolean' })
}
const prefs = await prisma.userNotificationPrefs.upsert({
where: { userId: req.userId },
create: {
userId: req.userId,
collaboratorChangesEnabled,
updatedAt: new Date()
},
update: {
collaboratorChangesEnabled,
updatedAt: new Date()
}
})
return res.json({
collaboratorChangesEnabled: prefs.collaboratorChangesEnabled
})
} catch (error: any) {
console.error('Error updating push prefs:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.put('/subscription', async (req: any, res) => {
try {
const { endpoint, keys, locale, userAgent } = req.body
if (!isValidHttpsEndpoint(endpoint)) {
return res.status(400).json({ error: 'Invalid push subscription endpoint' })
}
if (!keys?.p256dh || !keys?.auth || typeof keys.p256dh !== 'string' || typeof keys.auth !== 'string') {
return res.status(400).json({ error: 'Invalid subscription keys' })
}
const normalizedLocale =
typeof locale === 'string' && (locale === 'de' || locale === 'en') ? locale : null
await prisma.pushSubscription.upsert({
where: { endpoint },
create: {
userId: req.userId,
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
locale: normalizedLocale,
userAgent: typeof userAgent === 'string' ? userAgent.slice(0, 512) : null
},
update: {
userId: req.userId,
p256dh: keys.p256dh,
auth: keys.auth,
locale: normalizedLocale,
userAgent: typeof userAgent === 'string' ? userAgent.slice(0, 512) : null,
updatedAt: new Date()
}
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error saving push subscription:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.delete('/subscription', async (req: any, res) => {
try {
const { endpoint } = req.body
if (!isValidHttpsEndpoint(endpoint)) {
return res.status(400).json({ error: 'Invalid push subscription endpoint' })
}
await prisma.pushSubscription.deleteMany({
where: {
endpoint,
userId: req.userId
}
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error deleting push subscription:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
export default router
+34
View File
@@ -1,5 +1,6 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { notifyOwnerOfCollaboratorChanges } from '../services/pushNotify.js'
const router = Router()
@@ -24,6 +25,27 @@ router.post('/push', async (req: any, res) => {
}
const results = []
const ownerNotifications = new Map<
string,
{ ownerId: string; logbookId: string; count: number }
>()
const recordCollaboratorChange = (
ownerId: string,
logbookId: string,
isOwner: boolean,
isCollaborator: unknown,
action: string,
type: string
) => {
if (isOwner || !isCollaborator) return
if (action !== 'create' && action !== 'update') return
if (type === 'logbook') return
const key = `${ownerId}:${logbookId}`
const entry = ownerNotifications.get(key) ?? { ownerId, logbookId, count: 0 }
entry.count += 1
ownerNotifications.set(key, entry)
}
for (const item of items) {
const { action, type, payloadId, logbookId, data, updatedAt } = item
@@ -218,6 +240,14 @@ router.post('/push', async (req: any, res) => {
}
}
recordCollaboratorChange(
logbook.userId,
logbookId,
isOwner,
isCollaborator,
action,
type
)
results.push({ payloadId, status: 'success' })
} catch (err: any) {
console.error(`Error processing sync item ${payloadId}:`, err)
@@ -225,6 +255,10 @@ router.post('/push', async (req: any, res) => {
}
}
for (const { ownerId, logbookId, count } of ownerNotifications.values()) {
void notifyOwnerOfCollaboratorChanges(logbookId, ownerId, req.userId, count)
}
return res.json({ results })
} catch (error: any) {
console.error('Error during sync push:', error)
+105
View File
@@ -0,0 +1,105 @@
import webpush from 'web-push'
import { prisma } from '../db.js'
const THROTTLE_MS = 3 * 60 * 1000
const lastSentByLogbook = new Map<string, number>()
let vapidConfigured = false
function ensureVapid(): boolean {
if (vapidConfigured) return true
const publicKey = process.env.VAPID_PUBLIC_KEY
const privateKey = process.env.VAPID_PRIVATE_KEY
const subject = process.env.VAPID_SUBJECT
if (!publicKey || !privateKey || !subject) {
return false
}
webpush.setVapidDetails(subject, publicKey, privateKey)
vapidConfigured = true
return true
}
function isThrottled(ownerUserId: string, logbookId: string): boolean {
const key = `${ownerUserId}:${logbookId}`
const last = lastSentByLogbook.get(key) ?? 0
return Date.now() - last < THROTTLE_MS
}
function markSent(ownerUserId: string, logbookId: string): void {
lastSentByLogbook.set(`${ownerUserId}:${logbookId}`, Date.now())
}
function notificationCopy(locale: string | null | undefined, changeCount: number): { title: string; body: string } {
const isDe = !locale || locale.startsWith('de')
const title = 'Kapteins Daagbok'
if (isDe) {
const body =
changeCount > 1
? `${changeCount} neue Änderungen in einem Ihrer Logbücher.`
: 'Neue Änderung in einem Ihrer Logbücher.'
return { title, body }
}
const body =
changeCount > 1
? `${changeCount} new changes in one of your logbooks.`
: 'New change in one of your logbooks.'
return { title, body }
}
export async function notifyOwnerOfCollaboratorChanges(
logbookId: string,
ownerUserId: string,
_actorUserId: string,
changeCount: number
): Promise<void> {
if (!ensureVapid() || changeCount < 1) return
if (isThrottled(ownerUserId, logbookId)) return
const prefs = await prisma.userNotificationPrefs.findUnique({
where: { userId: ownerUserId }
})
if (!prefs?.collaboratorChangesEnabled) return
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId: ownerUserId }
})
if (subscriptions.length === 0) return
markSent(ownerUserId, logbookId)
const payloadBase = {
tag: `logbook-change-${logbookId}`,
renotify: false,
data: {
url: `/?logbook=${encodeURIComponent(logbookId)}`,
logbookId,
changeCount
}
}
await Promise.allSettled(
subscriptions.map(async (sub) => {
const { title, body } = notificationCopy(sub.locale, changeCount)
const payload = JSON.stringify({ title, body, ...payloadBase })
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth }
},
payload
)
} catch (err: unknown) {
const statusCode =
err && typeof err === 'object' && 'statusCode' in err
? (err as { statusCode: number }).statusCode
: undefined
if (statusCode === 404 || statusCode === 410) {
await prisma.pushSubscription.delete({ where: { id: sub.id } }).catch(() => {})
} else {
console.warn('[push] Failed to send notification:', err)
}
}
})
)
}