Compare commits

...

24 Commits

Author SHA1 Message Date
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
elpatron 95cf42d1f6 chore: release v0.1.0.14 2026-05-29 20:16:55 +02:00
elpatron 95cfc3872b fix: Sync-Warteschlange im Online-Modus zuverlässig leeren
Lösch-Sync schlug serverseitig an JSON.parse('') fehl; clientseitig werden Duplikate zusammengeführt, parallele Läufe nachgeholt und die Queue bis zum Leeren durchgeschoben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:15:47 +02:00
elpatron bb85e799cf chore: release v0.1.0.13 2026-05-29 19:58:12 +02:00
elpatron 32f1fa1d79 feat: Logbuch-Statistik mit Strecken, Verbrauch und Segel/Motor
Neuer Sidebar-Tab aggregiert Reisetage pro Logbuch oder Account: KPIs, Hafenkette, Multi-Track-Karte, Tages-Etmale und Verbrauchsdiagramme.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 19:56:27 +02:00
elpatron f70e31dfb6 docs: README.md mit Projektübersicht im Root ergänzen
Beschreibt Funktionen, Architektur, lokale Entwicklung und Deployment für neue Mitwirkende.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 19:37:14 +02:00
elpatron 4f1702ba2a chore: release v0.1.0.12 2026-05-29 19:21:01 +02:00
elpatron a4c7fcfc6f feat: Plausible-Event Photo Uploaded für Logbuch und Crew
Trackt Foto-Uploads in Reisetagen und Crew-Profilen mit context- und role-Properties.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 19:20:50 +02:00
elpatron e3aeae1966 feat: SEO- und Social-Meta-Tags in index.html ergänzen
Description, Canonical, Open Graph und Twitter Cards für kapteins-daagbok.eu verbessern die Auffindbarkeit und Link-Vorschauen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 19:15:32 +02:00
elpatron 760b369b39 chore: release v0.1.0.11 2026-05-29 19:10:46 +02:00
elpatron 166afac18a fix: __APP_VERSION__ global in vite-env.d.ts für tsc -b deklarieren
Das Modul-Export machte die Version-Deklaration unsichtbar und brach den Docker-Frontend-Build.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 19:10:37 +02:00
elpatron cd2467d1fd chore: release v0.1.0.10 2026-05-29 19:08:43 +02:00
elpatron 9502719816 fix: stopTour als Onboarding Tour Skipped statt Completed tracken
Vorzeitige Tour-Abbrüche über stopTour wurden fälschlich als Abschluss gezählt und verfälschten den Onboarding-Funnel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 19:05:54 +02:00
elpatron 2926d743fb feat: Plausible Analytics mit 18 Custom Events
Trackt zentrale Nutzeraktionen (Auth, Logbuch, Reisetage, Kollaboration, Onboarding, Export) über einen typisierten Analytics-Service und dokumentiert alle Events für Plausible Goals.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 19:02:41 +02:00
elpatron f04a91d640 chore: release v0.1.0.9 2026-05-29 18:42:55 +02:00
elpatron 571c93cfe1 fix: PWA-Update-Hinweis nach „Später“ für eine Stunde unterdrücken
dismissUpdate setzt jetzt suppressUpdatePrompt, damit onNeedRefresh den Banner
nicht sofort erneut anzeigt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 18:42:40 +02:00
32 changed files with 3182 additions and 111 deletions
+158
View File
@@ -0,0 +1,158 @@
# Kapteins Daagbok
Digitales Yacht-Logbuch als Progressive Web App (PWA) — offline-fähig, End-to-End-verschlüsselt und mit Passkey-Anmeldung.
**Live:** [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
## Überblick
Kapteins Daagbok richtet sich an private Skipper und Yachtbesitzer, die ihr Bordlogbuch digital führen möchten. Die App speichert Schiffsdaten, Crew-Profile und Reisetage (Törns) in einem Format, das an übliche nautische Logbuch-Vorlagen angelehnt ist.
Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API). Der Server sieht nur ciphertext — eine Zero-Knowledge-Architektur. Daten liegen zusätzlich lokal in IndexedDB (Dexie.js) und synchronisieren im Hintergrund, sodass die App **auch offline** auf See nutzbar ist.
## Funktionen
- **Passkey-Authentifizierung** (WebAuthn) mit optionaler Recovery-Phrase und lokalem PIN-Fallback
- **Mehrere Logbücher** pro Benutzerkonto
- **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
- **Read-only-Freigabe** — öffentlicher Lese-Link für Dritte
- **Export** — PDF pro Reisetag, CSV-Download/-Teilen
- **PWA** — installierbar auf iOS/Android, Offline-Modus, Update-Hinweise
- **Mehrsprachig** — Deutsch und Englisch
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer
## Architektur
```
┌─────────────────┐ HTTPS/API ┌─────────────────┐
│ React PWA │ ◄──────────────────► │ Express API │
│ Vite + Dexie │ (nur ciphertext) │ Prisma + PG │
│ IndexedDB │ │ PostgreSQL │
└─────────────────┘ └─────────────────┘
```
| Schicht | Technologie |
|---------|-------------|
| Frontend | React 19, TypeScript, Vite, vite-plugin-pwa |
| Lokaler Speicher | Dexie.js (IndexedDB), Hintergrund-Sync |
| Backend | Node.js, Express, Prisma |
| Datenbank | PostgreSQL 16 |
| Auth | WebAuthn (Passkeys) via `@simplewebauthn` |
| Krypto | Web Crypto API (AES-GCM), BIP39 Recovery |
## Projektstruktur
```
kapteins-daagbok/
├── client/ # React-PWA (Frontend)
│ ├── src/
│ │ ├── components/ # UI-Komponenten
│ │ ├── services/ # Auth, Sync, Krypto, Analytics, …
│ │ └── i18n/ # DE/EN-Übersetzungen
│ └── Dockerfile # Nginx-Produktions-Image
├── server/ # Express-API + Prisma
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign
│ └── prisma/ # Datenbankschema
├── docs/ # Projektdokumentation (z. B. Plausible Events)
├── scripts/ # Dev- und Deploy-Skripte
├── docker-compose.yml # Produktions-Stack (DB + Backend + Frontend)
└── VERSION # App-Version (Build & Footer)
```
## Voraussetzungen
- **Node.js** 20+
- **npm**
- **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack)
- Optional: OpenWeatherMap-API-Key (Wetter-Abruf in den Einstellungen)
## Lokale Entwicklung
### 1. Abhängigkeiten installieren
```bash
cd server && npm ci && cd ..
cd client && npm ci && cd ..
```
### 2. Umgebungsvariablen
```bash
cp .env.example .env
```
Für lokale Passkeys: `RP_ID=localhost`, `ORIGIN=http://localhost:5173` (bzw. die tatsächliche Frontend-URL).
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
```
### 3. Datenbank & Schema
Das Dev-Skript startet PostgreSQL in Docker (`postgres-daagbox`). Schema anwenden:
```bash
cd server && npx prisma db push && cd ..
```
### 4. Dev-Server starten
```bash
./scripts/start-dev.sh
```
| Dienst | URL |
|--------|-----|
| Frontend (Vite) | http://localhost:5173 |
| Backend API | http://localhost:5000 |
| Health Check | http://localhost:5000/api/health |
## Docker (produktionsnah)
Gesamten Stack lokal bauen und starten:
```bash
./scripts/start-dev-docker.sh
```
Frontend: http://localhost · API: http://localhost/api/health
Umgebungsvariablen für Passkeys in `.env` setzen (`RP_ID`, `ORIGIN`).
## Deployment
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
```bash
./scripts/update-prod.sh
```
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
## Dokumentation
| Dokument | Inhalt |
|----------|--------|
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [.planning/PROJECT.md](.planning/PROJECT.md) | Produktvision und Anforderungen (GSD) |
## Analytics
Die App nutzt [Plausible Analytics](https://plausible.io/) (self-hosted) für anonyme Nutzungsmetriken — ohne Cookies und ohne personenbezogene Daten in Event-Properties. Details und Goal-Namen: [docs/plausible-events.md](docs/plausible-events.md).
## Version
Aktuelle Version: siehe [VERSION](VERSION) (wird im App-Footer und beim Docker-Build eingebunden).
---
© 2026 Markus F.J. Busche · [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
+1 -1
View File
@@ -1 +1 @@
0.1.0.9
0.1.0.18
+23 -2
View File
@@ -1,16 +1,37 @@
<!doctype html>
<html lang="en">
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren auch offline als PWA." />
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch" />
<meta name="author" content="Markus F.J. Busche" />
<meta name="robots" content="index, follow" />
<meta name="application-name" content="Kapteins Daagbok" />
<link rel="canonical" href="https://kapteins-daagbok.eu/" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#1e293b" />
<link rel="apple-touch-icon" href="/logo.png" />
<title>Kapteins Daagbok</title>
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Kapteins Daagbok" />
<meta property="og:title" content="Kapteins Daagbok Digitales Yacht-Logbuch" />
<meta property="og:description" content="Sicheres, E2E-verschlüsseltes Logbuch für Skipper: Reisetage, GPS-Tracks, Crew- und Schiffsdaten mit Passkey-Anmeldung und Offline-PWA." />
<meta property="og:url" content="https://kapteins-daagbok.eu/" />
<meta property="og:image" content="https://kapteins-daagbok.eu/logo.png" />
<meta property="og:image:alt" content="Kapteins Daagbok Logo" />
<meta property="og:locale" content="de_DE" />
<meta property="og:locale:alternate" content="en_US" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Kapteins Daagbok Digitales Yacht-Logbuch" />
<meta name="twitter:description" content="Sicheres, E2E-verschlüsseltes Logbuch für Skipper: Reisetage, GPS-Tracks, Crew- und Schiffsdaten mit Passkey-Anmeldung und Offline-PWA." />
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
<title>Kapteins Daagbok Digitales Yacht-Logbuch</title>
</head>
<body>
<div id="root"></div>
+452
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);
@@ -999,6 +1035,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);
@@ -2109,6 +2152,325 @@ html.theme-cupertino .events-scroll-container {
background: rgba(255, 255, 255, 0.8);
}
/* --- Statistics dashboard --- */
.stats-subtitle {
margin: 4px 0 0;
font-size: 14px;
color: var(--app-text-muted);
font-weight: 400;
}
.stats-scope-toggle {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 20px;
}
.stats-scope-toggle .btn {
width: auto;
padding: 10px 18px;
}
.stats-kpi-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-top: 24px;
}
.stats-kpi-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border-radius: var(--app-radius-card);
border: 1px solid var(--app-border-subtle);
}
.stats-kpi-icon {
color: var(--app-accent-light);
flex-shrink: 0;
margin-top: 2px;
}
.stats-kpi-label {
display: block;
font-size: 12px;
color: var(--app-text-muted);
margin-bottom: 4px;
}
.stats-kpi-value {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
color: var(--app-text-primary);
}
.stats-kpi-unit {
font-size: 13px;
font-weight: 500;
color: var(--app-text-muted);
margin-left: 4px;
}
.stats-section-title {
margin: 0 0 8px;
font-size: 17px;
font-weight: 600;
}
.stats-section-sub {
margin: 0 0 16px;
font-size: 13px;
color: var(--app-text-muted);
}
.stats-route-chain {
margin: 0;
font-size: 15px;
line-height: 1.6;
color: var(--app-text-primary);
}
.stats-route-arrow {
color: var(--app-accent-light);
font-weight: 600;
}
.stats-multi-track-map {
min-height: 320px;
}
.stats-track-legend {
display: flex;
flex-wrap: wrap;
gap: 12px 18px;
margin-top: 12px;
font-size: 13px;
color: var(--app-text-muted);
}
.stats-track-legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.stats-track-legend-swatch {
width: 14px;
height: 14px;
border-radius: 3px;
flex-shrink: 0;
}
.stats-bar-chart {
display: flex;
align-items: flex-end;
gap: 8px;
min-height: 180px;
padding-top: 8px;
overflow-x: auto;
}
.stats-bar-column {
display: flex;
flex-direction: column;
align-items: center;
min-width: 52px;
flex: 1 0 52px;
}
.stats-bar-column--grouped {
min-width: 44px;
}
.stats-bar-value {
font-size: 10px;
color: var(--app-text-muted);
min-height: 14px;
margin-bottom: 4px;
}
.stats-bar-track {
width: 100%;
max-width: 36px;
height: 120px;
display: flex;
align-items: flex-end;
justify-content: center;
background: var(--app-surface-muted);
border-radius: 6px 6px 2px 2px;
overflow: hidden;
}
.stats-bar-track--short {
max-width: 14px;
height: 100px;
}
.stats-bar {
width: 100%;
border-radius: 4px 4px 0 0;
min-height: 2px;
transition: height 0.2s ease;
}
.stats-bar--distance {
background: linear-gradient(180deg, var(--app-accent-light), var(--app-accent));
}
.stats-bar--fuel {
background: linear-gradient(180deg, #fbbf24, #d97706);
}
.stats-bar--water {
background: linear-gradient(180deg, #38bdf8, #0284c7);
}
.stats-bar-label {
margin-top: 8px;
font-size: 11px;
color: var(--app-text-muted);
text-align: center;
}
.stats-bar-sublabel {
font-size: 10px;
color: var(--app-text-muted);
opacity: 0.85;
}
.stats-bar-group {
display: flex;
gap: 4px;
align-items: flex-end;
height: 100px;
}
.stats-consumption-chart {
flex-direction: column;
align-items: stretch;
min-height: auto;
}
.stats-consumption-chart .stats-bar-column--grouped {
display: inline-flex;
vertical-align: bottom;
}
.stats-consumption-chart {
display: block;
overflow-x: auto;
white-space: nowrap;
padding-bottom: 8px;
}
.stats-consumption-chart .stats-bar-column--grouped {
display: inline-flex;
white-space: normal;
}
.stats-consumption-legend {
display: flex;
gap: 20px;
margin-top: 16px;
font-size: 13px;
color: var(--app-text-muted);
}
.stats-consumption-legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.stats-legend-swatch {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
}
.stats-propulsion-bar {
display: flex;
width: 100%;
height: 18px;
border-radius: 999px;
overflow: hidden;
background: var(--app-surface-muted);
}
.stats-propulsion-segment--sail {
background: var(--app-accent);
}
.stats-propulsion-segment--motor {
background: #d97706;
}
.stats-propulsion-segment--unknown {
background: var(--app-text-muted);
opacity: 0.45;
}
.stats-propulsion-labels {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
margin-top: 12px;
font-size: 13px;
color: var(--app-text-primary);
}
.stats-hint {
margin: 12px 0 0;
font-size: 12px;
color: var(--app-text-muted);
}
.stats-account-table-wrap {
overflow-x: auto;
}
.stats-account-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.stats-account-table th,
.stats-account-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--app-border-subtle);
}
.stats-account-table th {
font-size: 12px;
font-weight: 600;
color: var(--app-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
@media (max-width: 1024px) {
.stats-kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.stats-kpi-grid {
grid-template-columns: 1fr;
}
.stats-kpi-value {
font-size: 20px;
}
}
.signature-grid {
align-items: start;
}
@@ -2656,6 +3018,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;
+63 -4
View File
@@ -8,11 +8,13 @@ import CrewForm from './components/CrewForm.tsx'
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
// import DeviationForm from './components/DeviationForm.tsx'
import LogEntriesList from './components/LogEntriesList.tsx'
import StatsDashboard from './components/StatsDashboard.tsx'
import SettingsForm from './components/SettingsForm.tsx'
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import AppTourOverlay from './components/AppTourOverlay.tsx'
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
import { getActiveMasterKey, logoutUser } from './services/auth.js'
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
import {
applyAppearanceToDocument,
resolveAppTheme,
@@ -24,9 +26,12 @@ import ReadOnlyViewer from './components/ReadOnlyViewer.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 } from 'lucide-react'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import {
@@ -57,6 +62,34 @@ function App() {
[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())
@@ -153,6 +186,7 @@ function App() {
const handleAuthenticated = async () => {
setIsAuthenticated(true)
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
try {
const demo = await seedDemoLogbookIfNeeded()
@@ -214,6 +248,7 @@ function App() {
<InvitationAcceptance
onAccepted={(logbookId, title) => {
setIsAuthenticated(true)
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
setIsAcceptingInvite(false)
selectLogbook(logbookId, title)
// Clean URL query parameters and hash anchor
@@ -263,8 +298,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>
@@ -334,6 +378,14 @@ function App() {
</button>
*/}
<button
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
>
<BarChart2 size={18} />
{t('nav.stats')}
</button>
<button
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
onClick={() => setActiveTab('settings')}
@@ -362,6 +414,10 @@ function App() {
<CrewForm logbookId={activeLogbookId} />
)}
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
<StatsDashboard logbookId={activeLogbookId} logbookTitle={activeLogbookTitle} />
)}
{/* Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert
{activeTab === 'deviation' && (
<DeviationForm logbookId={activeLogbookId} />
@@ -369,7 +425,10 @@ function App() {
*/}
{activeTab === 'settings' && (
<SettingsForm logbookId={activeLogbookId} />
<SettingsForm
logbookId={activeLogbookId}
onLogbookRestored={selectLogbook}
/>
)}
</main>
</div>
+5
View File
@@ -5,6 +5,7 @@ import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { useDialog } from './ModalDialog.tsx'
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
@@ -236,6 +237,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
})
setSkipperSuccess(true)
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: 'skipper', action: 'update' })
setTimeout(() => setSkipperSuccess(false), 3000)
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
@@ -337,6 +339,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
}
setShowMemberForm(false)
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: 'crew', action: isNew ? 'create' : 'update' })
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to save crew member:', err)
@@ -452,6 +455,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
try {
const resized = await resizeImageFile(file)
setSkipPhoto(resized)
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'crew', role: 'skipper' })
} catch (err: any) {
setSkipPhotoError(err.message || 'Failed to process image')
}
@@ -659,6 +663,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
try {
const resized = await resizeImageFile(file)
setMemPhoto(resized)
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'crew', role: 'crew' })
} catch (err: any) {
setMemPhotoError(err.message || 'Failed to process image')
}
@@ -10,8 +10,10 @@ 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'
interface InvitationAcceptanceProps {
onAccepted: (logbookId: string, title: string) => void
@@ -181,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) {
@@ -189,11 +194,13 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
encryptedTitle: rawEncryptedTitle,
updatedAt: new Date().toISOString(),
isSynced: 1,
isShared: 1
isShared: 1,
collaborationRole
})
}
await syncLogbook(logbookId)
trackPlausibleEvent(PlausibleEvents.INVITE_ACCEPTED)
onAccepted(logbookId, decryptedTitle)
} catch (err: any) {
console.error('Accepting invitation failed:', err)
+5
View File
@@ -7,6 +7,7 @@ import { decryptJson, encryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import { useDialog } from './ModalDialog.tsx'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
@@ -157,6 +158,7 @@ export default function LogEntriesList({
} else {
await downloadCsv(logbookId, title)
}
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
} catch (err: any) {
console.error('Failed to download CSV:', err)
setError(err.message || 'Failed to generate CSV export.')
@@ -175,6 +177,7 @@ export default function LogEntriesList({
} else {
await shareCsv(logbookId, title)
}
trackPlausibleEvent(PlausibleEvents.CSV_SHARED)
} catch (err: any) {
if (err.message === 'share_unsupported') {
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
@@ -204,6 +207,7 @@ export default function LogEntriesList({
} else {
await downloadLogbookPagePdf(logbookId, entryId, date)
}
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
} catch (err: any) {
console.error('Failed to download PDF:', err)
setError(err.message || 'Failed to generate PDF export.')
@@ -291,6 +295,7 @@ export default function LogEntriesList({
// Open immediately in details editor
setSelectedEntryId(localId)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_CREATED)
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to create entry:', err)
+6
View File
@@ -23,6 +23,7 @@ import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import {
getDecryptedTrack,
saveUploadedTrack,
@@ -284,6 +285,7 @@ export default function LogEntryEditor({
setSignSkipper(signature)
setEntryHash(hash)
lockedContentHashRef.current = hash
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
}
const handlePasskeySignCrew = async () => {
@@ -300,6 +302,7 @@ export default function LogEntryEditor({
setSignCrew(signature)
setEntryHash(hash)
lockedContentHashRef.current = hash
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
}
// Auto-calculate Freshwater Consumption
@@ -465,6 +468,7 @@ export default function LogEntryEditor({
await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
applyTrackStats(parsedWps)
await loadTrack()
trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED)
} catch (err: any) {
console.error('File parsing failed:', err)
setUploadError(err.message || 'Failed to parse track file.')
@@ -731,6 +735,7 @@ export default function LogEntryEditor({
setError(null)
try {
await downloadLogbookPagePdf(logbookId, entryId, date)
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
} catch (err: any) {
console.error('Failed to download PDF:', err)
setError(err.message || 'Failed to generate PDF export.')
@@ -782,6 +787,7 @@ export default function LogEntryEditor({
})
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
setTimeout(() => {
setSuccess(false)
onBack()
@@ -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>
)
}
+76 -37
View File
@@ -3,6 +3,8 @@ 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'
import AccountDangerZone from './AccountDangerZone.tsx'
@@ -70,6 +72,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
const created = await createLogbook(newTitle.trim())
setLogbooks((prev) => [created, ...prev])
setNewTitle('')
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
} catch (err: any) {
setError(err.message || 'Failed to create logbook')
} finally {
@@ -80,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 {
@@ -104,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 */}
@@ -199,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>
)
}
+179
View File
@@ -0,0 +1,179 @@
import { Component, useEffect, useMemo, useRef } from 'react'
import type { ErrorInfo, ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import L from 'leaflet'
import type { TrackSegment } from '../services/statsAggregation.js'
import { getTrackColor } from '../services/statsAggregation.js'
import type { TrackWaypoint } from '../services/trackUpload.js'
interface MultiTrackMapProps {
segments: TrackSegment[]
}
const LINE_WEIGHT = 4
const LINE_OPACITY = 0.88
function isValidWaypoint(wp: TrackWaypoint): boolean {
return Number.isFinite(Number(wp.lat)) && Number.isFinite(Number(wp.lng))
}
function toLatLngs(waypoints: TrackWaypoint[]): [number, number][] {
return waypoints
.filter(isValidWaypoint)
.map((wp) => [Number(wp.lat), Number(wp.lng)] as [number, number])
}
function MultiTrackMapInner({ segments }: MultiTrackMapProps) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement | null>(null)
const segmentsKey = useMemo(
() =>
segments
.map((seg) =>
seg.waypoints
.filter(isValidWaypoint)
.map((wp) => `${seg.entryId}:${wp.lat},${wp.lng}`)
.join('|')
)
.join('||'),
[segments]
)
useEffect(() => {
const container = containerRef.current
if (!container || segments.length === 0) return
let cancelled = false
const pendingFrames: number[] = []
const map = L.map(container, {
zoomControl: true,
attributionControl: true
})
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'
}).addTo(map)
L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: 'Map data &copy; <a href="http://openseamap.org">OpenSeaMap</a> contributors'
}).addTo(map)
const trackGroup = L.layerGroup().addTo(map)
const allLatLngs: [number, number][] = []
for (const segment of segments) {
const latLngs = toLatLngs(segment.waypoints)
if (latLngs.length < 2) continue
allLatLngs.push(...latLngs)
const color = getTrackColor(segment.colorIndex)
L.polyline(latLngs, {
color,
weight: LINE_WEIGHT,
opacity: LINE_OPACITY,
lineCap: 'round',
lineJoin: 'round'
})
.addTo(trackGroup)
.bindPopup(t('stats.day_label', { day: segment.dayOfTravel }))
L.circleMarker(latLngs[0], {
radius: 7,
fillColor: color,
fillOpacity: 0.95,
color: '#ffffff',
weight: 2
})
.addTo(trackGroup)
.bindPopup(`${t('stats.day_label', { day: segment.dayOfTravel })} ${t('logs.track_map_start')}`)
}
if (allLatLngs.length > 0) {
pendingFrames.push(
requestAnimationFrame(() => {
if (cancelled) return
map.invalidateSize({ animate: false })
pendingFrames.push(
requestAnimationFrame(() => {
if (cancelled) return
try {
const bounds = L.latLngBounds(allLatLngs.map(([lat, lng]) => L.latLng(lat, lng)))
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [24, 24], maxZoom: 12, animate: false })
}
} catch {
map.setView(allLatLngs[0], 11, { animate: false })
}
})
)
})
)
}
return () => {
cancelled = true
pendingFrames.forEach((id) => cancelAnimationFrame(id))
map.remove()
}
}, [segmentsKey, segments, t])
if (segments.length === 0) return null
return (
<div className="track-map-wrapper">
<div
className="track-map-container stats-multi-track-map"
ref={containerRef}
aria-label={t('stats.route_map_title')}
/>
<div className="stats-track-legend" aria-hidden="true">
{segments.map((seg) => (
<span key={seg.entryId} className="stats-track-legend-item">
<span
className="stats-track-legend-swatch"
style={{ backgroundColor: getTrackColor(seg.colorIndex) }}
/>
{t('stats.day_label', { day: seg.dayOfTravel })}
</span>
))}
</div>
</div>
)
}
class MultiTrackMapErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('MultiTrackMap render failed:', error, info)
}
render() {
if (this.state.hasError) return this.props.fallback
return this.props.children
}
}
export default function MultiTrackMap(props: MultiTrackMapProps) {
const { t } = useTranslation()
return (
<MultiTrackMapErrorBoundary
fallback={<div className="track-error-msg">{t('logs.track_map_error')}</div>}
>
<MultiTrackMapInner {...props} />
</MultiTrackMapErrorBoundary>
)
}
+3 -1
View File
@@ -5,6 +5,7 @@ import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react'
@@ -159,7 +160,8 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
setCaption('')
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to process image:', err)
+10 -1
View File
@@ -2,15 +2,18 @@ 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 { useDialog } from './ModalDialog.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import ThemedSelect from './ThemedSelect.tsx'
import { useAppTour } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
interface SettingsFormProps {
logbookId?: string | null
onLogbookRestored?: (logbookId: string, title: string) => void
}
interface Collaborator {
@@ -28,7 +31,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()
@@ -208,6 +211,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}`
setInviteLink(link)
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
} catch (err: any) {
console.error('Failed to generate invite:', err)
showAlert(err.message || 'Failed to generate invite link.')
@@ -452,6 +456,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' }}>
+417
View File
@@ -0,0 +1,417 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge } from 'lucide-react'
import MultiTrackMap from './MultiTrackMap.tsx'
import {
formatLiters,
formatNm,
loadAccountStats,
loadLogbookStats,
type LogbookStatsSummary,
type StatsTotals,
type TravelDayStats
} from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
interface StatsDashboardProps {
logbookId: string
logbookTitle: string
}
type StatsScope = 'logbook' | 'account'
function maxBarValue(days: TravelDayStats[], pick: (d: TravelDayStats) => number): number {
if (days.length === 0) return 1
return Math.max(1, ...days.map(pick))
}
function KpiCard({
icon,
label,
value,
unit
}: {
icon: ReactNode
label: string
value: string
unit?: string
}) {
return (
<div className="stats-kpi-card glass">
<div className="stats-kpi-icon">{icon}</div>
<div className="stats-kpi-body">
<span className="stats-kpi-label">{label}</span>
<span className="stats-kpi-value">
{value}
{unit ? <span className="stats-kpi-unit">{unit}</span> : null}
</span>
</div>
</div>
)
}
function TotalsGrid({ totals }: { totals: StatsTotals }) {
const { t } = useTranslation()
return (
<div className="stats-kpi-grid">
<KpiCard
icon={<Gauge size={20} />}
label={t('stats.total_distance')}
value={formatNm(totals.totalDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Anchor size={20} />}
label={t('stats.travel_days')}
value={String(totals.travelDayCount)}
/>
<KpiCard
icon={<Sailboat size={20} />}
label={t('stats.sail_distance')}
value={formatNm(totals.sailDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Gauge size={20} />}
label={t('stats.motor_distance')}
value={formatNm(totals.motorDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Fuel size={20} />}
label={t('stats.fuel_total')}
value={formatLiters(totals.totalFuelL)}
unit={t('stats.unit_l')}
/>
<KpiCard
icon={<Droplets size={20} />}
label={t('stats.water_total')}
value={formatLiters(totals.totalFreshwaterL)}
unit={t('stats.unit_l')}
/>
</div>
)
}
function DailyBarChart({
days,
valueFn,
barClass,
formatValue
}: {
days: TravelDayStats[]
valueFn: (d: TravelDayStats) => number
barClass: string
formatValue: (v: number) => string
}) {
const { t } = useTranslation()
const max = maxBarValue(days, valueFn)
return (
<div className="stats-bar-chart" role="img" aria-label={t('stats.daily_etmal')}>
{days.map((day) => {
const value = valueFn(day)
const heightPct = max > 0 ? Math.max(2, (value / max) * 100) : 0
const label = day.date
? new Date(day.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })
: t('stats.day_label', { day: day.dayOfTravel })
return (
<div key={day.entryId} className="stats-bar-column" title={`${label}: ${formatValue(value)}`}>
<span className="stats-bar-value">{value > 0 ? formatValue(value) : ''}</span>
<div className="stats-bar-track">
<div className={`stats-bar ${barClass}`} style={{ height: `${heightPct}%` }} />
</div>
<span className="stats-bar-label">{label}</span>
<span className="stats-bar-sublabel">{t('stats.day_label', { day: day.dayOfTravel })}</span>
</div>
)
})}
</div>
)
}
function ConsumptionChart({ days }: { days: TravelDayStats[] }) {
const { t } = useTranslation()
const max = maxBarValue(days, (d) => Math.max(d.fuelConsumptionL, d.freshwaterConsumptionL))
return (
<div className="stats-bar-chart stats-consumption-chart" role="img" aria-label={t('stats.daily_consumption')}>
{days.map((day) => {
const fuelH = max > 0 ? Math.max(2, (day.fuelConsumptionL / max) * 100) : 0
const waterH = max > 0 ? Math.max(2, (day.freshwaterConsumptionL / max) * 100) : 0
const label = day.date
? new Date(day.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })
: t('stats.day_label', { day: day.dayOfTravel })
return (
<div key={day.entryId} className="stats-bar-column stats-bar-column--grouped">
<div className="stats-bar-group">
<div className="stats-bar-track stats-bar-track--short">
<div className="stats-bar stats-bar--fuel" style={{ height: `${fuelH}%` }} />
</div>
<div className="stats-bar-track stats-bar-track--short">
<div className="stats-bar stats-bar--water" style={{ height: `${waterH}%` }} />
</div>
</div>
<span className="stats-bar-label">{label}</span>
</div>
)
})}
<div className="stats-consumption-legend">
<span><span className="stats-legend-swatch stats-bar--fuel" /> {t('stats.fuel_legend')}</span>
<span><span className="stats-legend-swatch stats-bar--water" /> {t('stats.water_legend')}</span>
</div>
</div>
)
}
function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
const { t } = useTranslation()
const total = totals.sailDistanceNm + totals.motorDistanceNm + totals.unknownPropulsionNm
if (total <= 0) return null
const sailPct = (totals.sailDistanceNm / total) * 100
const motorPct = (totals.motorDistanceNm / total) * 100
const unknownPct = (totals.unknownPropulsionNm / total) * 100
return (
<div className="stats-propulsion">
<div className="stats-propulsion-bar" role="img" aria-label={t('stats.propulsion_title')}>
{totals.sailDistanceNm > 0 && (
<div className="stats-propulsion-segment stats-propulsion-segment--sail" style={{ width: `${sailPct}%` }} />
)}
{totals.motorDistanceNm > 0 && (
<div className="stats-propulsion-segment stats-propulsion-segment--motor" style={{ width: `${motorPct}%` }} />
)}
{totals.unknownPropulsionNm > 0 && (
<div className="stats-propulsion-segment stats-propulsion-segment--unknown" style={{ width: `${unknownPct}%` }} />
)}
</div>
<div className="stats-propulsion-labels">
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span>
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)</span>
{totals.unknownPropulsionNm > 0 && (
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
)}
</div>
<p className="stats-hint">{t('stats.propulsion_hint')}</p>
</div>
)
}
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
const { t } = useTranslation()
const { travelDays, routePorts, trackSegments, totals } = summary
if (travelDays.length === 0) {
return <div className="dashboard-status-msg">{t('stats.no_data')}</div>
}
return (
<>
<TotalsGrid totals={totals} />
{routePorts.length > 0 && (
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.route_overview')}</h3>
<p className="stats-route-chain">
{routePorts.map((port, idx) => (
<span key={`${port}-${idx}`}>
{idx > 0 && <span className="stats-route-arrow"> </span>}
{port}
</span>
))}
</p>
</div>
)}
{trackSegments.length > 0 && (
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.route_map_title')}</h3>
<MultiTrackMap segments={trackSegments} />
</div>
)}
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_etmal')}</h3>
<p className="stats-section-sub">
{t('stats.avg_distance')}: {formatNm(totals.avgDistancePerDayNm)} {t('stats.unit_nm')}
</p>
<DailyBarChart
days={travelDays}
valueFn={(d) => d.distanceNm}
barClass="stats-bar--distance"
formatValue={formatNm}
/>
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
<p className="stats-section-sub">
{t('stats.avg_fuel')}: {formatLiters(totals.avgFuelPerDayL)} {t('stats.unit_l')}
{' · '}
{t('stats.avg_water')}: {formatLiters(totals.avgFreshwaterPerDayL)} {t('stats.unit_l')}
{totals.fuelPerNmL != null && (
<> · {t('stats.fuel_per_nm')}: {totals.fuelPerNmL} {t('stats.unit_l')}/{t('stats.unit_nm')}</>
)}
</p>
<ConsumptionChart days={travelDays} />
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
<PropulsionBreakdown totals={totals} />
</div>
</>
)
}
export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboardProps) {
const { t } = useTranslation()
const [scope, setScope] = useState<StatsScope>('logbook')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [lb, acc] = await Promise.all([
loadLogbookStats(logbookId, logbookTitle, true),
loadAccountStats(false)
])
setLogbookStats(lb)
setAccountStats(acc)
} catch (err: unknown) {
console.error('Failed to load statistics:', err)
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
} finally {
setLoading(false)
}
}, [logbookId, logbookTitle])
useEffect(() => {
void loadData()
}, [loadData])
const accountLogbooksWithDays = useMemo(
() => accountStats?.logbooks.filter((lb) => lb.travelDays.length > 0) ?? [],
[accountStats]
)
const allAccountDays = useMemo(() => {
if (!accountStats) return []
const days = accountStats.logbooks.flatMap((lb) => lb.travelDays)
return [...days].sort(compareTravelDaysChronological)
}, [accountStats])
return (
<div className="form-card">
<div className="form-header">
<BarChart2 size={24} className="form-icon" />
<div>
<h2>{t('stats.title')}</h2>
<p className="stats-subtitle">{t('stats.subtitle')}</p>
</div>
</div>
<div className="stats-scope-toggle" role="tablist" aria-label={t('stats.scope_label')}>
<button
type="button"
role="tab"
aria-selected={scope === 'logbook'}
className={`btn ${scope === 'logbook' ? 'primary' : 'secondary'}`}
onClick={() => setScope('logbook')}
>
{t('stats.scope_logbook')}
</button>
<button
type="button"
role="tab"
aria-selected={scope === 'account'}
className={`btn ${scope === 'account' ? 'primary' : 'secondary'}`}
onClick={() => setScope('account')}
>
{t('stats.scope_account')}
</button>
</div>
{error && <div className="auth-error mt-4">{error}</div>}
{loading ? (
<div className="tab-placeholder mt-6">
<BarChart2 className="header-logo spin" size={48} />
<p>{t('stats.loading')}</p>
</div>
) : scope === 'logbook' && logbookStats ? (
<LogbookScopeView summary={logbookStats} />
) : scope === 'account' && accountStats ? (
<>
<TotalsGrid totals={accountStats.totals} />
{accountLogbooksWithDays.length === 0 ? (
<div className="dashboard-status-msg mt-6">{t('stats.no_data')}</div>
) : (
<>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.account_logbooks')}</h3>
<div className="stats-account-table-wrap">
<table className="stats-account-table">
<thead>
<tr>
<th>{t('stats.col_logbook')}</th>
<th>{t('stats.travel_days')}</th>
<th>{t('stats.total_distance')}</th>
<th>{t('stats.fuel_total')}</th>
<th>{t('stats.water_total')}</th>
</tr>
</thead>
<tbody>
{accountLogbooksWithDays.map((lb) => (
<tr key={lb.logbookId}>
<td>{lb.title}</td>
<td>{lb.totals.travelDayCount}</td>
<td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</td>
<td>{formatLiters(lb.totals.totalFuelL)} {t('stats.unit_l')}</td>
<td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{accountStats.totals.travelDayCount > 0 && (
<>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_etmal')}</h3>
<DailyBarChart
days={allAccountDays}
valueFn={(d) => d.distanceNm}
barClass="stats-bar--distance"
formatValue={formatNm}
/>
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
<ConsumptionChart days={allAccountDays} />
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
<PropulsionBreakdown totals={accountStats.totals} />
</div>
</>
)}
</>
)}
</>
) : null}
</div>
)
}
+2
View File
@@ -5,6 +5,7 @@ import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
interface VesselFormProps {
@@ -251,6 +252,7 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
})
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED)
setTimeout(() => setSuccess(false), 3000)
// Trigger background sync task
+22 -12
View File
@@ -14,8 +14,9 @@ import {
markTourCompleted
} from '../services/appTourStorage.js'
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export type AppTab = 'vessel' | 'crew' | 'logs' | 'settings'
export type AppTab = 'vessel' | 'crew' | 'logs' | 'stats' | 'settings'
export type TourStepId =
| 'welcome'
@@ -117,25 +118,34 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
setIsActive(true)
}, [])
const finishTour = useCallback(() => {
const dismissTour = useCallback((outcome: 'completed' | 'skipped', stepIndexAtDismiss: number) => {
const userId = localStorage.getItem('active_userid')
if (userId) markTourCompleted(userId)
if (outcome === 'completed') {
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED)
} else {
const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome'
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step })
}
setIsActive(false)
setStepIndex(0)
}, [])
const stopTour = finishTour
const skipTour = finishTour
const stopTour = useCallback(() => {
dismissTour('skipped', stepIndex)
}, [dismissTour, stepIndex])
const skipTour = useCallback(() => {
dismissTour('skipped', stepIndex)
}, [dismissTour, stepIndex])
const nextStep = useCallback(() => {
setStepIndex((current) => {
if (current + 1 >= STEP_ORDER.length) {
finishTour()
return current
}
return current + 1
})
}, [finishTour])
if (stepIndex + 1 >= STEP_ORDER.length) {
dismissTour('completed', stepIndex)
return
}
setStepIndex(stepIndex + 1)
}, [dismissTour, stepIndex])
const prevStep = useCallback(() => {
setStepIndex((current) => Math.max(0, current - 1))
+4 -2
View File
@@ -4,6 +4,7 @@ import { useRegisterSW } from 'virtual:pwa-register/react'
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
const UPDATE_SUPPRESS_MS = 30_000
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
const UPDATE_RELOAD_FALLBACK_MS = 2000
function isUpdateSuppressed(): boolean {
@@ -11,8 +12,8 @@ function isUpdateSuppressed(): boolean {
return Date.now() < suppressUntil
}
function suppressUpdatePrompt(): void {
sessionStorage.setItem(UPDATE_SUPPRESS_KEY, String(Date.now() + UPDATE_SUPPRESS_MS))
function suppressUpdatePrompt(durationMs = UPDATE_SUPPRESS_MS): void {
sessionStorage.setItem(UPDATE_SUPPRESS_KEY, String(Date.now() + durationMs))
}
function clearUpdateSuppression(): void {
@@ -94,6 +95,7 @@ export function usePwaUpdate() {
const dismissUpdate = () => {
setNeedRefresh(false)
suppressUpdatePrompt(UPDATE_DISMISS_SUPPRESS_MS)
}
return { needRefresh, updateApp, dismissUpdate }
+80 -3
View File
@@ -10,6 +10,7 @@
"crew": "Crew-Liste",
"deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge",
"stats": "Statistik",
"settings": "Einstellungen"
},
"auth": {
@@ -230,12 +231,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",
@@ -311,7 +321,41 @@
"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",
"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",
@@ -337,6 +381,39 @@
"logbook_title": "Demo-Logbuch Ostsee",
"badge": "Demo"
},
"stats": {
"title": "Statistik",
"subtitle": "Strecken, Verbrauch und Antriebsart auf einen Blick",
"scope_label": "Auswertungsbereich",
"scope_logbook": "Dieses Logbuch",
"scope_account": "Alle Logbücher",
"loading": "Statistik wird berechnet…",
"no_data": "Noch keine Reisetage vorhanden.",
"total_distance": "Gesamtstrecke",
"travel_days": "Reisetage",
"sail_distance": "Unter Segel",
"motor_distance": "Maschinenfahrt",
"unknown_propulsion": "Unbekannt",
"fuel_total": "Kraftstoff gesamt",
"water_total": "Wasser gesamt",
"daily_etmal": "Tages-Etmale",
"daily_consumption": "Tagesverbrauch",
"route_overview": "Route",
"route_map_title": "Streckenübersicht",
"propulsion_title": "Segel vs. Maschine",
"propulsion_hint": "Die Aufteilung basiert auf den Logbuch-Events pro Reisetag, nicht auf GPS-Segmenten.",
"avg_distance": "Ø pro Reisetag",
"avg_fuel": "Ø Kraftstoff",
"avg_water": "Ø Wasser",
"fuel_per_nm": "Kraftstoff pro sm",
"fuel_legend": "Kraftstoff",
"water_legend": "Wasser",
"unit_nm": "sm",
"unit_l": "L",
"day_label": "Tag {{day}}",
"account_logbooks": "Logbücher im Überblick",
"col_logbook": "Logbuch"
},
"tour": {
"skip": "Tour überspringen",
"back": "Zurück",
+80 -3
View File
@@ -10,6 +10,7 @@
"crew": "Crew List",
"deviation": "Deviation Table",
"logs": "Logbook Entries",
"stats": "Statistics",
"settings": "Settings"
},
"auth": {
@@ -230,12 +231,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",
@@ -311,7 +321,41 @@
"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",
"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",
@@ -337,6 +381,39 @@
"logbook_title": "Baltic Sea Demo Logbook",
"badge": "Demo"
},
"stats": {
"title": "Statistics",
"subtitle": "Routes, consumption and propulsion at a glance",
"scope_label": "Scope",
"scope_logbook": "This logbook",
"scope_account": "All logbooks",
"loading": "Calculating statistics…",
"no_data": "No travel days yet.",
"total_distance": "Total distance",
"travel_days": "Travel days",
"sail_distance": "Under sail",
"motor_distance": "Engine",
"unknown_propulsion": "Unknown",
"fuel_total": "Total fuel",
"water_total": "Total water",
"daily_etmal": "Daily mileage",
"daily_consumption": "Daily consumption",
"route_overview": "Route",
"route_map_title": "Route overview",
"propulsion_title": "Sail vs. engine",
"propulsion_hint": "Split is based on logbook events per travel day, not GPS segments.",
"avg_distance": "Avg. per travel day",
"avg_fuel": "Avg. fuel",
"avg_water": "Avg. water",
"fuel_per_nm": "Fuel per nm",
"fuel_legend": "Fuel",
"water_legend": "Water",
"unit_nm": "nm",
"unit_l": "L",
"day_label": "Day {{day}}",
"account_logbooks": "Logbooks overview",
"col_logbook": "Logbook"
},
"tour": {
"skip": "Skip tour",
"back": "Back",
+36
View File
@@ -0,0 +1,36 @@
export const PlausibleEvents = {
ACCOUNT_CREATED: 'Account Created',
LOGGED_IN: 'Logged In',
LOGBOOK_CREATED: 'Logbook Created',
TRAVEL_DAY_CREATED: 'Travel Day Created',
TRAVEL_DAY_SAVED: 'Travel Day Saved',
ENTRY_SIGNED: 'Entry Signed',
LOGBOOK_DELETED: 'Logbook Deleted',
ACCOUNT_DELETED: 'Account Deleted',
GPS_TRACK_UPLOADED: 'GPS Track Uploaded',
VESSEL_SAVED: 'Vessel Saved',
CREW_SAVED: 'Crew Saved',
ONBOARDING_TOUR_COMPLETED: 'Onboarding Tour Completed',
ONBOARDING_TOUR_SKIPPED: 'Onboarding Tour Skipped',
INVITE_GENERATED: 'Invite Generated',
INVITE_ACCEPTED: 'Invite Accepted',
PDF_EXPORTED: 'PDF Exported',
CSV_EXPORTED: 'CSV Exported',
CSV_SHARED: 'CSV Shared',
PHOTO_UPLOADED: 'Photo Uploaded',
BACKUP_EXPORTED: 'Backup Exported',
BACKUP_RESTORED: 'Backup Restored'
} as const
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean>
export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
if (typeof window.plausible !== 'function') return
if (props && Object.keys(props).length > 0) {
window.plausible(name, { props })
return
}
window.plausible(name)
}
+3
View File
@@ -11,6 +11,7 @@ import {
bufferToBase64
} from './crypto.js'
import { clearLogbookKeysCache } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { db } from './db.js'
const API_BASE = '/api/auth'
@@ -255,6 +256,7 @@ export async function registerUser(username: string): Promise<RegistrationResult
localStorage.setItem('active_userid', result.userId)
rememberUsername(username)
sessionStorage.setItem('seed_demo_logbook', '1')
trackPlausibleEvent(PlausibleEvents.ACCOUNT_CREATED)
}
return {
@@ -545,6 +547,7 @@ export async function deleteAccount(): Promise<boolean> {
// Wipe localStorage and session variables
logoutUser()
trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
return true
}
} catch (err) {
+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 {
+42 -10
View File
@@ -2,15 +2,35 @@ import { db, type LocalLogbook } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
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
}
@@ -100,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)
@@ -130,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
})
}
@@ -206,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) {
@@ -237,7 +267,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
title,
updatedAt: now,
isSynced: false,
isShared: false
isShared: false,
accessRole: 'OWNER'
}
}
@@ -303,4 +334,5 @@ export async function deleteLogbook(id: string): Promise<void> {
// Perform local cascading cleanup
await deleteLocalLogbookCache(id)
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
}
+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)
}
+251
View File
@@ -0,0 +1,251 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { decryptLogbookTitle } from './logbook.js'
import { getDecryptedTrack, type TrackWaypoint } from './trackUpload.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import type { LogEntryPayloadInput } from '../utils/logEntryPayload.js'
import {
parseEventDistanceNm,
splitDistanceByPropulsion
} from '../utils/propulsionStats.js'
export type DistanceSource = 'gps' | 'events' | 'none'
export interface TravelDayStats {
entryId: string
logbookId: string
date: string
dayOfTravel: string
departure: string
destination: string
distanceNm: number
distanceSource: DistanceSource
fuelConsumptionL: number
freshwaterConsumptionL: number
sailDistanceNm: number
motorDistanceNm: number
unknownPropulsionNm: number
hasGpsTrack: boolean
}
export interface TrackSegment {
entryId: string
dayOfTravel: string
label: string
waypoints: TrackWaypoint[]
colorIndex: number
}
export interface LogbookStatsSummary {
logbookId: string
title: string
travelDays: TravelDayStats[]
routePorts: string[]
trackSegments: TrackSegment[]
totals: StatsTotals
}
export interface AccountStatsSummary {
logbooks: LogbookStatsSummary[]
totals: StatsTotals
}
export interface StatsTotals {
travelDayCount: number
daysWithGps: number
totalDistanceNm: number
sailDistanceNm: number
motorDistanceNm: number
unknownPropulsionNm: number
totalFuelL: number
totalFreshwaterL: number
avgDistancePerDayNm: number
avgFuelPerDayL: number
avgFreshwaterPerDayL: number
fuelPerNmL: number | null
}
const TRACK_COLORS = [
'#3b82f6',
'#10b981',
'#f59e0b',
'#8b5cf6',
'#ec4899',
'#06b6d4',
'#ef4444',
'#84cc16'
]
function resolveDistanceNm(payload: LogEntryPayloadInput): { distanceNm: number; distanceSource: DistanceSource } {
const gpsDistance = Number(payload.trackDistanceNm) || 0
if (gpsDistance > 0) {
return { distanceNm: gpsDistance, distanceSource: 'gps' }
}
const eventSum = (payload.events || []).reduce(
(sum, event) => sum + parseEventDistanceNm(event.distance),
0
)
if (eventSum > 0) {
return { distanceNm: Number(eventSum.toFixed(2)), distanceSource: 'events' }
}
return { distanceNm: 0, distanceSource: 'none' }
}
function buildTotals(days: TravelDayStats[]): StatsTotals {
const travelDayCount = days.length
const daysWithGps = days.filter((d) => d.hasGpsTrack).length
const totalDistanceNm = days.reduce((sum, d) => sum + d.distanceNm, 0)
const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0)
const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0)
const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 0)
const totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 0)
const totalFreshwaterL = days.reduce((sum, d) => sum + d.freshwaterConsumptionL, 0)
return {
travelDayCount,
daysWithGps,
totalDistanceNm: Number(totalDistanceNm.toFixed(2)),
sailDistanceNm: Number(sailDistanceNm.toFixed(2)),
motorDistanceNm: Number(motorDistanceNm.toFixed(2)),
unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)),
totalFuelL: Number(totalFuelL.toFixed(1)),
totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)),
avgDistancePerDayNm:
travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0,
avgFuelPerDayL:
travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0,
avgFreshwaterPerDayL:
travelDayCount > 0 ? Number((totalFreshwaterL / travelDayCount).toFixed(1)) : 0,
fuelPerNmL:
totalDistanceNm > 0 && totalFuelL > 0
? Number((totalFuelL / totalDistanceNm).toFixed(2))
: null
}
}
export function buildRoutePorts(days: TravelDayStats[]): string[] {
const ports: string[] = []
for (const day of days) {
const dep = day.departure.trim()
const dest = day.destination.trim()
if (dep && (ports.length === 0 || ports[ports.length - 1] !== dep)) {
ports.push(dep)
}
if (dest && (ports.length === 0 || ports[ports.length - 1] !== dest)) {
ports.push(dest)
}
}
return ports
}
async function loadTravelDaysForLogbook(
logbookId: string,
includeTracks: boolean
): Promise<{ days: TravelDayStats[]; trackSegments: TrackSegment[] }> {
const masterKey = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!masterKey) {
throw new Error('Encryption key not found. Please log in.')
}
const localEntries = await db.entries.where({ logbookId }).toArray()
const days: TravelDayStats[] = []
const trackSegments: TrackSegment[] = []
for (const entry of localEntries) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (!decrypted) continue
const payload = decrypted as LogEntryPayloadInput
const { distanceNm, distanceSource } = resolveDistanceNm(payload)
const propulsion = splitDistanceByPropulsion(distanceNm, payload.events || [])
let hasGpsTrack = false
if (includeTracks) {
const track = await getDecryptedTrack(entry.payloadId)
if (track && track.waypoints.length >= 2) {
hasGpsTrack = true
trackSegments.push({
entryId: entry.payloadId,
dayOfTravel: payload.dayOfTravel || '',
label: payload.dayOfTravel || '?',
waypoints: track.waypoints,
colorIndex: trackSegments.length % TRACK_COLORS.length
})
}
} else {
hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId))
}
days.push({
entryId: entry.payloadId,
logbookId,
date: payload.date || '',
dayOfTravel: payload.dayOfTravel || '',
departure: payload.departure || '',
destination: payload.destination || '',
distanceNm,
distanceSource,
fuelConsumptionL: Number(payload.fuel?.consumption) || 0,
freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0,
sailDistanceNm: propulsion.sailDistanceNm,
motorDistanceNm: propulsion.motorDistanceNm,
unknownPropulsionNm: propulsion.unknownPropulsionNm,
hasGpsTrack
})
}
days.sort(compareTravelDaysChronological)
trackSegments.sort((a, b) => Number(a.dayOfTravel) - Number(b.dayOfTravel))
return { days, trackSegments }
}
export async function loadLogbookStats(
logbookId: string,
title: string,
includeTracks = true
): Promise<LogbookStatsSummary> {
const { days, trackSegments } = await loadTravelDaysForLogbook(logbookId, includeTracks)
return {
logbookId,
title,
travelDays: days,
routePorts: buildRoutePorts(days),
trackSegments,
totals: buildTotals(days)
}
}
export async function loadAccountStats(includeTracks = false): Promise<AccountStatsSummary> {
const logbooks = await db.logbooks.toArray()
const summaries: LogbookStatsSummary[] = []
for (const lb of logbooks) {
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
summaries.push(await loadLogbookStats(lb.id, title, includeTracks))
}
summaries.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
const allDays = summaries.flatMap((s) => s.travelDays)
return {
logbooks: summaries,
totals: buildTotals(allDays)
}
}
export function getTrackColor(index: number): string {
return TRACK_COLORS[index % TRACK_COLORS.length]
}
export function formatNm(value: number): string {
return value.toFixed(2)
}
export function formatLiters(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
+131 -13
View File
@@ -1,8 +1,9 @@
import { db } from './db.js'
import { db, type SyncQueueItem } from './db.js'
import { getActiveMasterKey } from './auth.js'
const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>()
const pendingResync = new Set<string>()
let isSyncing = false
const listeners = new Set<(syncing: boolean) => void>()
@@ -27,13 +28,108 @@ function isNewer(timeA: string | Date, timeB: string | Date): boolean {
return new Date(timeA).getTime() > new Date(timeB).getTime()
}
function entityKey(item: SyncQueueItem): string {
return `${item.type}:${item.payloadId}`
}
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
const byEntity = new Map<string, SyncQueueItem[]>()
for (const item of pending) {
const key = entityKey(item)
const group = byEntity.get(key)
if (group) group.push(item)
else byEntity.set(key, [item])
}
const kept: SyncQueueItem[] = []
const staleIds: number[] = []
for (const group of byEntity.values()) {
const winner = await resolveCoalescedItem(group)
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)
}
}
}
}
if (staleIds.length > 0) {
await db.syncQueue.bulkDelete(staleIds)
}
return kept.sort((a, b) => (a.id ?? 0) - (b.id ?? 0))
}
function scheduleResync(logbookId: string) {
if (pendingResync.has(logbookId)) return
pendingResync.add(logbookId)
queueMicrotask(() => {
pendingResync.delete(logbookId)
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
})
}
// Push local sync queue items to the server
async function pushChanges(logbookId: string): Promise<boolean> {
const userId = localStorage.getItem('active_userid')
if (!userId) return false
// Fetch all pending queue items for this logbook
const pending = await db.syncQueue.where({ logbookId }).toArray()
const pending = await coalesceSyncQueue(logbookId)
if (pending.length === 0) return true
try {
@@ -53,13 +149,14 @@ async function pushChanges(logbookId: string): Promise<boolean> {
const { results } = await response.json()
// Process results
for (const res of results) {
// Match results by index — payloadId alone is not unique in the queue
for (let i = 0; i < results.length; i++) {
const res = results[i]
const queueItem = pending[i]
if (!queueItem) continue
if (res.status === 'success' || res.status === 'conflict') {
// Find matching queue item
const queueItem = pending.find((item) => item.payloadId === res.payloadId)
if (queueItem && queueItem.id !== undefined) {
// Delete from sync queue
if (queueItem.id !== undefined) {
await db.syncQueue.delete(queueItem.id)
}
} else {
@@ -73,6 +170,21 @@ async function pushChanges(logbookId: string): Promise<boolean> {
}
}
async function flushPushQueue(logbookId: string): Promise<boolean> {
let ok = true
for (let attempt = 0; attempt < 5; attempt++) {
const before = await db.syncQueue.where({ logbookId }).count()
if (before === 0) return ok
const pushed = await pushChanges(logbookId)
ok = ok && pushed
const after = await db.syncQueue.where({ logbookId }).count()
if (after === 0 || after === before) break
}
return ok
}
// Pull updates from the server and apply last-write-wins
async function pullChanges(logbookId: string): Promise<boolean> {
const userId = localStorage.getItem('active_userid')
@@ -266,14 +378,20 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
const masterKey = getActiveMasterKey()
if (!masterKey) return false
if (syncingLogbooks.has(logbookId)) return false
if (syncingLogbooks.has(logbookId)) {
scheduleResync(logbookId)
return false
}
syncingLogbooks.add(logbookId)
setSyncing(true)
try {
const pushed = await pushChanges(logbookId)
const pushed = await flushPushQueue(logbookId)
const pulled = await pullChanges(logbookId)
return pushed && pulled;
// Push again in case pull surfaced nothing but queue grew during pull
const pushedAfterPull = await flushPushQueue(logbookId)
return pushed && pulled && pushedAfterPull
} finally {
syncingLogbooks.delete(logbookId)
setSyncing(syncingLogbooks.size > 0)
@@ -304,7 +422,7 @@ export async function syncAllLogbooks(): Promise<void> {
}
// Setup background sync intervals
let syncIntervalId: any = null
let syncIntervalId: ReturnType<typeof setInterval> | null = null
export function startBackgroundSync(intervalMs = 30000) {
if (syncIntervalId) clearInterval(syncIntervalId)
+58
View File
@@ -0,0 +1,58 @@
import type { LogEventPayload } from './logEntryPayload.js'
export type PropulsionMode = 'sail' | 'motor'
const MOTOR_LABELS = ['Maschinenfahrt', 'Engine Propulsion']
export function isMotorPropulsion(sailsOrMotor: string): boolean {
const normalized = sailsOrMotor.trim().toLowerCase()
if (!normalized) return false
return MOTOR_LABELS.some((label) => normalized.includes(label.toLowerCase()))
}
export function classifyEventPropulsion(event: Pick<LogEventPayload, 'sailsOrMotor'>): PropulsionMode {
return isMotorPropulsion(event.sailsOrMotor) ? 'motor' : 'sail'
}
export interface PropulsionDistanceSplit {
sailDistanceNm: number
motorDistanceNm: number
unknownPropulsionNm: number
}
export function splitDistanceByPropulsion(
distanceNm: number,
events: Pick<LogEventPayload, 'sailsOrMotor'>[]
): PropulsionDistanceSplit {
if (distanceNm <= 0) {
return { sailDistanceNm: 0, motorDistanceNm: 0, unknownPropulsionNm: 0 }
}
const classified = events.filter((e) => e.sailsOrMotor.trim())
if (classified.length === 0) {
return { sailDistanceNm: 0, motorDistanceNm: 0, unknownPropulsionNm: distanceNm }
}
let motorCount = 0
let sailCount = 0
for (const event of classified) {
if (isMotorPropulsion(event.sailsOrMotor)) {
motorCount++
} else {
sailCount++
}
}
const total = motorCount + sailCount
const motorDistanceNm = Number(((distanceNm * motorCount) / total).toFixed(2))
const sailDistanceNm = Number((distanceNm - motorDistanceNm).toFixed(2))
return { sailDistanceNm, motorDistanceNm, unknownPropulsionNm: 0 }
}
export function parseEventDistanceNm(distance: string): number {
const match = distance.replace(',', '.').match(/(\d+(?:\.\d+)?)/)
if (!match) return 0
const value = Number(match[1])
return Number.isFinite(value) && value > 0 ? value : 0
}
+10 -2
View File
@@ -1,9 +1,17 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />
declare const __APP_VERSION__: string
declare module '*?raw' {
const content: string
export default content
}
declare global {
const __APP_VERSION__: string
interface Window {
plausible?: (event: string, options?: { props?: Record<string, string | number | boolean> }) => void
}
}
export {}
+63
View File
@@ -0,0 +1,63 @@
# Plausible Custom Events
Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js` auf der Domain `kapteins-daagbok.eu`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
**Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.).
## Setup
1. Script in `client/index.html` (bereits eingebunden)
2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen)
## Event-Übersicht
| Event | Auslöser | Properties |
|-------|----------|------------|
| Account Created | Erfolgreiche Registrierung (`auth.ts`) | — |
| Logged In | Login oder Einladungs-Flow abgeschlossen (`App.tsx`) | — |
| Logbook Created | Neues Logbuch im Dashboard (`LogbookDashboard.tsx`) | — |
| Logbook Deleted | Logbuch gelöscht (`logbook.ts`) | — |
| Travel Day Created | Neuer Reisetag über „+“ in der Eintragsliste (`LogEntriesList.tsx`) | — |
| Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — |
| Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` |
| GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — |
| 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`) |
| 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` |
## Bewusst nicht getrackt
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
## Typische Funnels (Plausible Goals)
Empfohlene Goal-Ketten für Auswertung:
1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved
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
```ts
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
```
Lokal ohne Plausible-Script ist `trackPlausibleEvent` ein No-Op. In Production im Browser-Netzwerk-Tab auf Requests an die Plausible-Instanz prüfen.
+28 -19
View File
@@ -103,15 +103,34 @@ router.post('/push', async (req: any, res) => {
continue
}
// Parse Payload parameters
if (action === 'delete') {
if (type === 'yacht') {
await prisma.yachtPayload.deleteMany({ where: { logbookId } })
} else if (type === 'deviation') {
await prisma.deviationPayload.deleteMany({ where: { logbookId } })
} else if (type === 'crew') {
await prisma.crewPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'entry') {
await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'photo') {
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'gpsTrack') {
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
} else {
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
continue
}
results.push({ payloadId, status: 'success' })
continue
}
// Parse payload for create/update operations
const parsed = JSON.parse(data)
const encryptedData = parsed.encryptedData || parsed.ciphertext
const { iv, tag } = parsed
if (type === 'yacht') {
if (action === 'delete') {
await prisma.yachtPayload.deleteMany({ where: { logbookId } })
} else {
{
const existing = await prisma.yachtPayload.findUnique({ where: { logbookId } })
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
@@ -124,9 +143,7 @@ router.post('/push', async (req: any, res) => {
})
}
} else if (type === 'deviation') {
if (action === 'delete') {
await prisma.deviationPayload.deleteMany({ where: { logbookId } })
} else {
{
const existing = await prisma.deviationPayload.findUnique({ where: { logbookId } })
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
@@ -139,9 +156,7 @@ router.post('/push', async (req: any, res) => {
})
}
} else if (type === 'crew') {
if (action === 'delete') {
await prisma.crewPayload.deleteMany({ where: { logbookId, payloadId } })
} else {
{
const existing = await prisma.crewPayload.findUnique({
where: { logbookId_payloadId: { logbookId, payloadId } }
})
@@ -156,9 +171,7 @@ router.post('/push', async (req: any, res) => {
})
}
} else if (type === 'entry') {
if (action === 'delete') {
await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } })
} else {
{
const existing = await prisma.entryPayload.findUnique({
where: { logbookId_payloadId: { logbookId, payloadId } }
})
@@ -173,9 +186,7 @@ router.post('/push', async (req: any, res) => {
})
}
} else if (type === 'photo') {
if (action === 'delete') {
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
} else {
{
const existing = await prisma.photoPayload.findUnique({
where: { logbookId_payloadId: { logbookId, payloadId } }
})
@@ -191,9 +202,7 @@ router.post('/push', async (req: any, res) => {
})
}
} else if (type === 'gpsTrack') {
if (action === 'delete') {
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
} else {
{
const existing = await prisma.gpsTrackPayload.findUnique({
where: { entryId: payloadId }
})