Compare commits

...

38 Commits

Author SHA1 Message Date
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
elpatron 7d5d9de3c1 chore: release v0.1.0.8 2026-05-29 18:40:06 +02:00
elpatron ab7670c3fc fix: PWA-Update-Button-Ladezustand nach Klick zurücksetzen
setUpdating(false) wieder im finally-Block, damit der Button nicht bis zum Reload hängen bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 18:39:53 +02:00
elpatron 41fb106153 feat: Zielhafen des Vortags als Start-Hafen für neuen Reisetag übernehmen
Erweitert die bestehende Vortags-Übernahme um den Starthafen im gleichen Bestätigungsdialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 18:33:43 +02:00
elpatron 268500237d fix: PWA-Update-Banner nach Reload zuverlässig ausblenden
needRefresh zurücksetzen, Reload-Fallback ergänzen und kurze Suppression nach Update,
damit die Benachrichtigung nicht erneut erscheint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 18:33:43 +02:00
elpatron 66a32e0367 chore: release v0.1.0.7 2026-05-29 18:18:26 +02:00
elpatron 819d84eaee feat: Registrierungs-Disclaimer und Header-Zugang
Neue Accounts sehen vor dem Onboarding Hinweise zu E2E, PWA, Sync und Haftung;
bestehende Nutzer können den Disclaimer jederzeit über einen Header-Button öffnen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 18:18:08 +02:00
elpatron 51ffc33f32 chore: release v0.1.0.6 2026-05-29 18:08:47 +02:00
elpatron 4c3f93602c fix: React-Hooks in Demo-Tour und LogEntriesList bereinigen
Tour-Schritte über zentralen Effect synchronisieren, Escape-Listener per Ref stabilisieren
und Eintragsliste nur bei Logbook-Wechsel bzw. Rückkehr aus dem Editor neu laden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 18:08:34 +02:00
elpatron 181cbe4895 fix: Kontrast der Onboarding-Tour im Dark Mode verbessern
Backdrop mit Aussparung für den Spotlight-Bereich, damit hervorgehobene UI-Elemente
in voller Helligkeit sichtbar bleiben; Tooltip und Rahmen kontrastreicher gestaltet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 18:03:43 +02:00
elpatron 0da855381d feat: Demo-Logbuch und Onboarding-Tour bei Registrierung
Neue Nutzer erhalten automatisch ein Demo-Logbuch mit drei Ostsee-Reisetagen
und eine interaktive App-Tour; die Tour kann in den Einstellungen erneut gestartet werden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:59:02 +02:00
elpatron 646d316a36 chore: release v0.1.0.5 2026-05-29 17:45:27 +02:00
elpatron 593d1aea20 fix: Memory-Leak bei PWA-Update-Checks durch Cleanup beheben.
Entfernt Listener und Intervalle beim erneuten SW-Register und beim Unmount des Hooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:44:31 +02:00
elpatron f01c5dc86f chore: release v0.1.0.4 2026-05-29 17:40:31 +02:00
elpatron 1f089fdaa7 feat: PWA-Updates erkennen und Nutzer zum Reload auffordern.
Wechselt auf prompt-Modus mit Update-Banner, periodischer SW-Prüfung und no-cache-Headern für Service Worker und index.html.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:40:23 +02:00
elpatron b2a28f5782 chore: release v0.1.0.3 2026-05-29 17:36:48 +02:00
elpatron 4d2e309967 fix: Einstellungs-Dropdowns durch ThemedSelect mit lesbarem Kontrast ersetzen.
Native Select-Optionen waren in Light/Dark Mode schlecht lesbar; ein eigenes Dropdown steuert Hintergrund und Textfarbe zuverlässig.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:35:57 +02:00
elpatron 2f6c668ca4 feat: Light Mode mit System-Erkennung und konfigurierbarem Erscheinungsbild.
Stellt hell/dunkel für Ocean, Material und Cupertino bereit, migriert die Kern-UI auf CSS-Variablen und ergänzt die Einstellungen inkl. i18n und Select-Kontrast.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:26:50 +02:00
elpatron 42736fedf3 fix: Schiffs-Maße als Zahlen statt Strings speichern
Länge, Tiefgang und Höhe werden beim Speichern geparst und numerisch persistiert; Legacy-String-Werte beim Laden weiter unterstützt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:10:26 +02:00
elpatron ac84fef832 fix: Auto-Accept-Retry bei fehlendem Logbuch-Schlüssel ermöglichen
autoAcceptStarted wird zurückgesetzt, wenn logbookKey oder logbookId fehlen, damit der Einladungsflow erneut starten kann.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:09:11 +02:00
elpatron 404eb79add feat: Schiffs-Stammdaten erweitern und Ablenkungstabelle ausblenden
Neue Felder für Yachttyp, Länge, Tiefgang und Höhe; Compass Deviation Table ist für Freizeit-Skipper vorerst aus der Navigation entfernt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:08:21 +02:00
elpatron 14b52c684d fix: Einladungs-Auto-Accept, isShared-Cache und Recovery-Validierung
Auto-Accept kann nach Session-Verlust erneut starten, isShared wird offline in Dexie persistiert, und leere Recovery-Benutzernamen werden abgefangen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:00:49 +02:00
49 changed files with 9028 additions and 466 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.3
0.1.0.16
+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>
+11
View File
@@ -3,6 +3,17 @@ server {
server_name localhost;
client_max_body_size 50M;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate";
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
+926 -332
View File
File diff suppressed because it is too large Load Diff
+123 -41
View File
@@ -5,29 +5,48 @@ import AuthOnboarding from './components/AuthOnboarding.tsx'
import LogbookDashboard from './components/LogbookDashboard.tsx'
import VesselForm from './components/VesselForm.tsx'
import CrewForm from './components/CrewForm.tsx'
import DeviationForm from './components/DeviationForm.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,
resolveColorScheme,
subscribeToSystemColorScheme
} from './services/appearance.js'
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
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 { db } from './services/db.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } 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 {
getStoredDemoFirstEntryId,
seedDemoLogbookIfNeeded
} from './services/demoLogbook.js'
function App() {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const { registerNavigation, requestStartAfterLogin } = useAppTour()
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'deviation' | 'logs' | 'settings'>('logs')
const [activeTab, setActiveTab] = useState<AppTab>('logs')
const [tourSelectedEntryId, setTourSelectedEntryId] = useState<string | null>(null)
const [demoHighlightEntryId, setDemoHighlightEntryId] = useState<string | null>(null)
const [online, setOnline] = useState(navigator.onLine)
const [isSyncing, setIsSyncing] = useState(false)
const [appliedTheme, setAppliedTheme] = useState<'ocean' | 'material' | 'cupertino'>('ocean')
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
// Viewer mode for read-only shared links
@@ -40,27 +59,16 @@ function App() {
[activeLogbookId]
)
const updateAppliedTheme = () => {
const configTheme = localStorage.getItem('active_theme') || 'auto'
if (configTheme === 'auto') {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera
if (/iPad|iPhone|iPod|Macintosh/.test(userAgent)) {
setAppliedTheme('cupertino')
} else if (/Android|Linux/.test(userAgent)) {
setAppliedTheme('material')
} else {
setAppliedTheme('ocean')
}
} else {
setAppliedTheme(configTheme as 'ocean' | 'material' | 'cupertino')
}
}
useEffect(() => {
updateAppliedTheme()
window.addEventListener('theme-changed', updateAppliedTheme)
const syncAppearance = () => {
applyAppearanceToDocument(resolveAppTheme(), resolveColorScheme())
}
syncAppearance()
window.addEventListener('appearance-changed', syncAppearance)
const unsubscribeSystem = subscribeToSystemColorScheme(syncAppearance)
return () => {
window.removeEventListener('theme-changed', updateAppliedTheme)
window.removeEventListener('appearance-changed', syncAppearance)
unsubscribeSystem()
}
}, [])
@@ -123,8 +131,46 @@ function App() {
}
}, [])
const handleAuthenticated = () => {
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId
})
}, [registerNavigation])
useEffect(() => {
if (isAuthenticated && activeLogbookId) {
setDemoHighlightEntryId(getStoredDemoFirstEntryId())
}
}, [isAuthenticated, activeLogbookId])
const selectLogbook = (id: string, title: string) => {
setActiveLogbookId(id)
setActiveLogbookTitle(title)
setActiveTab('logs')
setTourSelectedEntryId(null)
localStorage.setItem('active_logbook_id', id)
localStorage.setItem('active_logbook_title', title)
}
const handleAuthenticated = async () => {
setIsAuthenticated(true)
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
try {
const demo = await seedDemoLogbookIfNeeded()
if (demo) {
selectLogbook(demo.logbookId, demo.title)
if (demo.firstEntryId) {
setDemoHighlightEntryId(demo.firstEntryId)
}
requestStartAfterLogin()
return
}
} catch (err) {
console.error('Failed to seed demo logbook:', err)
}
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
if (savedLogbookId && savedLogbookTitle) {
@@ -138,27 +184,28 @@ function App() {
setIsAuthenticated(false)
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setTourSelectedEntryId(null)
setDemoHighlightEntryId(null)
localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title')
}
const handleSelectLogbook = (id: string, title: string) => {
setActiveLogbookId(id)
setActiveLogbookTitle(title)
localStorage.setItem('active_logbook_id', id)
localStorage.setItem('active_logbook_title', title)
}
const handleBackToDashboard = () => {
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setTourSelectedEntryId(null)
localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title')
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
}
if (isViewerMode) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div style={{ display: 'contents' }}>
<ReadOnlyViewer token={shareToken} hexKey={shareKey} />
</div>
)
@@ -166,12 +213,13 @@ function App() {
if (isAcceptingInvite) {
return (
<div className={`theme-${appliedTheme} auth-screen`}>
<div className="auth-screen">
<InvitationAcceptance
onAccepted={(logbookId, title) => {
setIsAuthenticated(true)
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
setIsAcceptingInvite(false)
handleSelectLogbook(logbookId, title)
selectLogbook(logbookId, title)
// Clean URL query parameters and hash anchor
window.history.replaceState({}, document.title, window.location.pathname)
}}
@@ -186,7 +234,7 @@ function App() {
if (!isAuthenticated) {
return (
<div className={`theme-${appliedTheme} auth-screen`}>
<div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} />
</div>
)
@@ -196,10 +244,10 @@ function App() {
if (!activeLogbookId) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
<LogbookDashboard
onSelectLogbook={handleSelectLogbook}
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
/>
</div>
@@ -207,7 +255,7 @@ function App() {
}
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
{isSyncing && <div className="sync-progress-bar" />}
<div className="app-layout">
@@ -237,6 +285,12 @@ function App() {
<span>{online ? 'Online' : t('sync.status_offline')}</span>
</div>
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
<Languages size={18} />
</button>
<DisclaimerHeaderButton />
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
</button>
@@ -250,6 +304,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')}
data-tour="nav-logs"
>
<FileText size={18} />
{t('nav.logs')}
@@ -258,6 +313,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => setActiveTab('vessel')}
data-tour="nav-vessel"
>
<Ship size={18} />
{t('nav.vessel')}
@@ -266,11 +322,13 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
data-tour="nav-crew"
>
<Users size={18} />
{t('nav.crew')}
</button>
{/* Compass Deviation Table — für Freizeit-Skipper vorerst ausgeblendet
<button
className={`sidebar-btn ${activeTab === 'deviation' ? 'active' : ''}`}
onClick={() => setActiveTab('deviation')}
@@ -278,6 +336,15 @@ function App() {
<Compass size={18} />
{t('nav.deviation')}
</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' : ''}`}
@@ -291,7 +358,12 @@ function App() {
{/* Tab Content Panels (Placeholder until Phase 3) */}
<main className="app-content">
{activeTab === 'logs' && (
<LogEntriesList logbookId={activeLogbookId} />
<LogEntriesList
logbookId={activeLogbookId}
controlledSelectedEntryId={tourSelectedEntryId}
onSelectedEntryIdChange={setTourSelectedEntryId}
highlightEntryId={demoHighlightEntryId}
/>
)}
{activeTab === 'vessel' && (
@@ -302,9 +374,15 @@ 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} />
)}
*/}
{activeTab === 'settings' && (
<SettingsForm logbookId={activeLogbookId} />
@@ -319,7 +397,11 @@ function App() {
export default function AppWrapper() {
return (
<DialogProvider>
<App />
<AppTourProvider>
<PwaUpdatePrompt />
<App />
<AppTourOverlay />
</AppTourProvider>
<AppFooter />
</DialogProvider>
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { X, ChevronLeft, ChevronRight } from 'lucide-react'
import {
getTourStepCopy,
getTourTargetSelector,
isCenteredTourStep,
useAppTour
} from '../context/AppTourContext.tsx'
interface SpotlightRect {
top: number
left: number
width: number
height: number
}
function buildCutoutClipPath(rect: SpotlightRect): string {
const right = rect.left + rect.width
const bottom = rect.top + rect.height
return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)`
}
export default function AppTourOverlay() {
const { t } = useTranslation()
const {
isActive,
currentStepId,
currentStepIndex,
totalSteps,
nextStep,
prevStep,
skipTour
} = useAppTour()
const [spotlight, setSpotlight] = useState<SpotlightRect | null>(null)
const skipTourRef = useRef(skipTour)
skipTourRef.current = skipTour
useLayoutEffect(() => {
if (!isActive || !currentStepId || isCenteredTourStep(currentStepId)) {
setSpotlight(null)
return
}
const updateSpotlight = () => {
const selector = getTourTargetSelector(currentStepId)
if (!selector) {
setSpotlight(null)
return
}
const el = document.querySelector(selector)
if (!el) {
setSpotlight(null)
return
}
const rect = el.getBoundingClientRect()
const padding = 8
setSpotlight({
top: Math.max(8, rect.top - padding),
left: Math.max(8, rect.left - padding),
width: rect.width + padding * 2,
height: rect.height + padding * 2
})
}
updateSpotlight()
window.addEventListener('resize', updateSpotlight)
window.addEventListener('scroll', updateSpotlight, true)
const timer = window.setTimeout(updateSpotlight, 120)
return () => {
window.clearTimeout(timer)
window.removeEventListener('resize', updateSpotlight)
window.removeEventListener('scroll', updateSpotlight, true)
}
}, [currentStepId, isActive])
useEffect(() => {
if (!isActive) return
document.body.classList.add('app-tour-active')
return () => document.body.classList.remove('app-tour-active')
}, [isActive])
useEffect(() => {
if (!isActive || !currentStepId || isCenteredTourStep(currentStepId)) return
const selector = getTourTargetSelector(currentStepId)
if (!selector) return
const el = document.querySelector(selector)
el?.classList.add('app-tour-target-active')
return () => el?.classList.remove('app-tour-target-active')
}, [currentStepId, isActive])
useEffect(() => {
if (!isActive) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') skipTourRef.current()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [isActive])
if (!isActive || !currentStepId) return null
const { title, body } = getTourStepCopy(currentStepId, t)
const centered = isCenteredTourStep(currentStepId)
const tooltipStyle = centered
? undefined
: spotlight
? {
top: Math.min(window.innerHeight - 220, spotlight.top + spotlight.height + 12),
left: Math.min(window.innerWidth - 340, Math.max(16, spotlight.left)),
maxWidth: '420px'
}
: { top: '20%', left: '50%', transform: 'translateX(-50%)', maxWidth: '420px' }
const backdropStyle = spotlight && !centered
? { clipPath: buildCutoutClipPath(spotlight) }
: undefined
return (
<div className="app-tour-root" role="dialog" aria-modal="true" aria-label={title}>
<div
className={`app-tour-backdrop${spotlight && !centered ? ' app-tour-backdrop--cutout' : ''}`}
style={backdropStyle}
onClick={skipTour}
/>
{!centered && spotlight && (
<div
className="app-tour-spotlight"
style={{
top: spotlight.top,
left: spotlight.left,
width: spotlight.width,
height: spotlight.height
}}
/>
)}
<div className={`app-tour-tooltip${centered ? ' centered' : ''}`} style={tooltipStyle}>
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
<X size={18} />
</button>
<p className="app-tour-progress">
{t('tour.progress', { current: currentStepIndex + 1, total: totalSteps })}
</p>
<h3 className="app-tour-title">{title}</h3>
<p className="app-tour-body">{body}</p>
<div className="app-tour-actions">
<button
type="button"
className="app-tour-link"
onClick={skipTour}
>
{t('tour.skip')}
</button>
<div className="app-tour-nav">
<button
type="button"
className="btn secondary app-tour-nav-btn"
onClick={prevStep}
disabled={currentStepIndex === 0}
>
<ChevronLeft size={16} />
{t('tour.back')}
</button>
<button type="button" className="btn primary app-tour-nav-btn" onClick={nextStep}>
{currentStepIndex === totalSteps - 1 ? t('tour.finish') : t('tour.next')}
{currentStepIndex < totalSteps - 1 && <ChevronRight size={16} />}
</button>
</div>
</div>
</div>
</div>
)
}
+26 -2
View File
@@ -12,6 +12,7 @@ import {
forgetUsername
} from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
interface AuthOnboardingProps {
onAuthenticated: () => void
@@ -45,6 +46,23 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
const [showPinLogin, setShowPinLogin] = useState(false)
const [pinLoginInput, setPinLoginInput] = useState('')
const [isNewRegistration, setIsNewRegistration] = useState(false)
const [showDisclaimer, setShowDisclaimer] = useState(false)
const finishAuth = () => {
if (isNewRegistration) {
setShowDisclaimer(true)
return
}
onAuthenticated()
}
const handleDisclaimerAccept = () => {
setIsNewRegistration(false)
setShowDisclaimer(false)
onAuthenticated()
}
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
if (!username.trim()) return
@@ -54,6 +72,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
try {
const result = await registerUser(username.trim())
if (result.verified) {
setIsNewRegistration(true)
setRecoveryPhrase(result.recoveryPhrase)
}
} catch (err: any) {
@@ -148,7 +167,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
const activeKey = getActiveMasterKey()
if (activeKey) {
await setLocalPin(pinInput.trim(), pinSetupUsername, activeKey)
onAuthenticated()
finishAuth()
} else {
setError('No active master key found')
}
@@ -198,6 +217,11 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
}
}
// Render 0: Registration disclaimer (new accounts only, before app onboarding)
if (showDisclaimer) {
return <RegistrationDisclaimer variant="accept" onDismiss={handleDisclaimerAccept} />
}
// Render 1: Display new registration recovery phrase
if (recoveryPhrase) {
return (
@@ -266,7 +290,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
<button
type="button"
className="btn secondary"
onClick={onAuthenticated}
onClick={finishAuth}
disabled={loading}
>
{t('auth.skip_pin')}
+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')
}
@@ -0,0 +1,24 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ScrollText } from 'lucide-react'
import DisclaimerModal from './DisclaimerModal.tsx'
export default function DisclaimerHeaderButton() {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<>
<button
type="button"
className="btn-icon"
onClick={() => setOpen(true)}
title={t('disclaimer.button_title')}
aria-label={t('disclaimer.button_title')}
>
<ScrollText size={18} />
</button>
<DisclaimerModal open={open} onClose={() => setOpen(false)} />
</>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { useEffect } from 'react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
interface DisclaimerModalProps {
open: boolean
onClose: () => void
}
export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps) {
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [open, onClose])
if (!open) return null
return (
<div className="disclaimer-modal-overlay" onClick={onClose}>
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
<RegistrationDisclaimer variant="view" onDismiss={onClose} />
</div>
</div>
)
}
+21 -4
View File
@@ -12,6 +12,7 @@ import { decryptJson, encryptBuffer } from '../services/crypto.js'
import { saveLogbookKey } from '../services/logbookKeys.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
@@ -137,13 +138,17 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const masterKey = getActiveMasterKey()
const activeUserId = localStorage.getItem('active_userid')
if (!masterKey || !activeUserId) {
autoAcceptStarted.current = false
setError(isDe
? 'Sitzung unvollständig — bitte erneut anmelden (Benutzer-ID fehlt).'
: 'Incomplete session — please log in again (user ID missing).')
setIsLoggedIn(false)
return
}
if (!logbookKey || !logbookId) return
if (!logbookKey || !logbookId) {
autoAcceptStarted.current = false
return
}
setAccepting(true)
setError(null)
@@ -184,11 +189,13 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
id: logbookId,
encryptedTitle: rawEncryptedTitle,
updatedAt: new Date().toISOString(),
isSynced: 1
isSynced: 1,
isShared: 1
})
}
await syncLogbook(logbookId)
trackPlausibleEvent(PlausibleEvents.INVITE_ACCEPTED)
onAccepted(logbookId, decryptedTitle)
} catch (err: any) {
console.error('Accepting invitation failed:', err)
@@ -202,7 +209,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
useEffect(() => {
if (loading || accepting || autoAcceptStarted.current) return
if (!isLoggedIn || !logbookId || !logbookKey || !token) return
if (!sessionReady()) return
if (!sessionReady()) {
autoAcceptStarted.current = false
return
}
autoAcceptStarted.current = true
void handleAccept()
@@ -240,10 +250,17 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
e.preventDefault()
if (!recoveryInput.trim() || !encryptedPayloads) return
const resolvedUser = (username.trim() || encryptedPayloads.username || '').trim()
if (!resolvedUser) {
setAuthError(isDe
? 'Benutzername konnte nicht ermittelt werden — bitte erneut anmelden.'
: 'Could not determine username — please try logging in again.')
return
}
setLoading(true)
setAuthError(null)
try {
const resolvedUser = username.trim() || encryptedPayloads.username
const success = await completeLoginWithRecovery(resolvedUser, recoveryInput.trim(), encryptedPayloads)
if (success) {
setShowRecoveryFallback(false)
+55 -17
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
@@ -7,15 +7,17 @@ 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'
import {
carryOverTankLevelsFromPreviousDay,
carryOverFromPreviousDay,
compareTravelDaysChronological,
emptyTankLevels,
formatTankLiters,
getNextTravelDayNumber,
hasCarryOverFromPreviousDay,
type LogEntryTankSource,
type TravelDaySortable
} from '../utils/logEntryTankLevels.js'
@@ -27,6 +29,9 @@ interface LogEntriesListProps {
preloadedEntries?: any[]
preloadedPhotos?: any[]
preloadedGpsTracks?: any[]
controlledSelectedEntryId?: string | null
onSelectedEntryIdChange?: (id: string | null) => void
highlightEntryId?: string | null
}
interface DecryptedEntryItem {
@@ -44,23 +49,32 @@ export default function LogEntriesList({
preloadedYacht,
preloadedEntries,
preloadedPhotos,
preloadedGpsTracks
preloadedGpsTracks,
controlledSelectedEntryId,
onSelectedEntryIdChange,
highlightEntryId
}: LogEntriesListProps) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const [entries, setEntries] = useState<DecryptedEntryItem[]>([])
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null)
const [internalSelectedEntryId, setInternalSelectedEntryId] = useState<string | null>(null)
const isEntrySelectionControlled = onSelectedEntryIdChange !== undefined
const selectedEntryId = isEntrySelectionControlled
? (controlledSelectedEntryId ?? null)
: internalSelectedEntryId
const setSelectedEntryId = (entryId: string | null) => {
if (isEntrySelectionControlled) {
onSelectedEntryIdChange?.(entryId)
} else {
setInternalSelectedEntryId(entryId)
}
}
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
useEffect(() => {
if (!selectedEntryId) {
loadEntries()
}
}, [logbookId, selectedEntryId])
const loadEntries = async () => {
const loadEntries = useCallback(async () => {
setLoading(true)
setError(null)
try {
@@ -119,7 +133,20 @@ export default function LogEntriesList({
} finally {
setLoading(false)
}
}
}, [logbookId, readOnly, preloadedEntries])
useEffect(() => {
loadEntries()
}, [loadEntries])
useEffect(() => {
const prevSelectedEntryId = prevSelectedEntryIdRef.current
prevSelectedEntryIdRef.current = selectedEntryId
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
loadEntries()
}
}, [selectedEntryId, loadEntries])
const handleDownloadCsv = async () => {
setExporting(true)
@@ -131,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.')
@@ -149,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'
@@ -178,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.')
@@ -203,11 +233,12 @@ export default function LogEntriesList({
decryptedEntries.sort(compareTravelDaysChronological)
const previousEntry = decryptedEntries.at(-1) ?? null
let { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
let { freshwater, fuel, departure } = carryOverFromPreviousDay(previousEntry)
if (previousEntry && (freshwater.morning > 0 || fuel.morning > 0)) {
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, departure })) {
const confirmed = await showConfirm(
t('logs.carry_over_tanks_confirm', {
departure: departure || '—',
fw: formatTankLiters(freshwater.morning),
fuel: formatTankLiters(fuel.morning)
}),
@@ -218,6 +249,7 @@ export default function LogEntriesList({
if (!confirmed) {
freshwater = emptyTankLevels()
fuel = emptyTankLevels()
departure = ''
}
}
@@ -230,7 +262,7 @@ export default function LogEntriesList({
const initialPayload = {
date: todayStr,
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
departure: '',
departure,
destination: '',
freshwater,
fuel,
@@ -263,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)
@@ -356,9 +389,14 @@ export default function LogEntriesList({
{entries.length === 0 ? (
<div className="dashboard-status-msg">{t('logs.no_entries')}</div>
) : (
<div className="logbooks-grid">
<div className="logbooks-grid" data-tour="entry-list">
{entries.map((item) => (
<div key={item.id} className="logbook-card glass" onClick={() => setSelectedEntryId(item.id)}>
<div
key={item.id}
className="logbook-card glass"
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
onClick={() => setSelectedEntryId(item.id)}
>
<div className="card-icon">
<FileText size={24} />
</div>
+7 -1
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()
@@ -1326,7 +1332,7 @@ export default function LogEntryEditor({
</div>
{/* Track file upload */}
<div className="form-card">
<div className="form-card" data-tour="entry-track">
<div className="form-header">
<Upload size={20} className="form-icon" />
<h3>{t('logs.track_upload_title')}</h3>
@@ -3,10 +3,12 @@ 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 { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
@@ -69,6 +71,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 {
@@ -149,6 +152,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<Languages size={18} />
</button>
<DisclaimerHeaderButton />
{/* Logout */}
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
@@ -209,6 +214,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<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',
+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)
+61
View File
@@ -0,0 +1,61 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RefreshCw, X } from 'lucide-react'
import { usePwaUpdate } from '../hooks/usePwaUpdate.js'
export default function PwaUpdatePrompt() {
const { t } = useTranslation()
const { needRefresh, updateApp, dismissUpdate } = usePwaUpdate()
const [updating, setUpdating] = useState(false)
if (!needRefresh) return null
const handleUpdate = async () => {
setUpdating(true)
try {
await updateApp()
} finally {
setUpdating(false)
}
}
return (
<div className="pwa-update-banner" role="alert" aria-live="polite">
<div className="pwa-update-icon" aria-hidden="true">
<RefreshCw size={22} />
</div>
<div className="pwa-update-body">
<p className="pwa-update-title">{t('pwa.update_title')}</p>
<p className="pwa-update-text">{t('pwa.update_desc')}</p>
</div>
<div className="pwa-update-actions">
<button
type="button"
className="btn primary pwa-update-btn"
onClick={handleUpdate}
disabled={updating}
>
{updating ? t('pwa.update_reloading') : t('pwa.update_now')}
</button>
<button
type="button"
className="pwa-update-link"
onClick={dismissUpdate}
>
{t('pwa.later')}
</button>
</div>
<button
type="button"
className="pwa-update-close"
onClick={dismissUpdate}
aria-label={t('pwa.later')}
>
<X size={18} />
</button>
</div>
)
}
@@ -0,0 +1,66 @@
import { useTranslation } from 'react-i18next'
import { ScrollText, X } from 'lucide-react'
export type DisclaimerVariant = 'accept' | 'view'
interface RegistrationDisclaimerProps {
onDismiss: () => void
variant?: DisclaimerVariant
}
export default function RegistrationDisclaimer({
onDismiss,
variant = 'accept'
}: RegistrationDisclaimerProps) {
const { t } = useTranslation()
const sections = [
{ title: t('disclaimer.e2e_title'), body: t('disclaimer.e2e_body') },
{ title: t('disclaimer.pwa_title'), body: t('disclaimer.pwa_body') },
{ title: t('disclaimer.storage_title'), body: t('disclaimer.storage_body') },
{ title: t('disclaimer.free_title'), body: t('disclaimer.free_body') },
{ title: t('disclaimer.liability_title'), body: t('disclaimer.liability_body') },
{ title: t('disclaimer.warranty_title'), body: t('disclaimer.warranty_body') }
]
return (
<div
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
role="document"
>
<div className="auth-header">
<ScrollText className="auth-icon accent" size={48} />
<h2>{t('disclaimer.title')}</h2>
{variant === 'view' && (
<button
type="button"
className="registration-disclaimer__close"
onClick={onDismiss}
aria-label={t('disclaimer.close')}
>
<X size={18} />
</button>
)}
</div>
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
<div className="registration-disclaimer__sections">
{sections.map((section) => (
<section key={section.title} className="registration-disclaimer__section">
<h3>{section.title}</h3>
<p>{section.body}</p>
</section>
))}
</div>
<p className="registration-disclaimer__copyright">{t('disclaimer.copyright')}</p>
<div className="auth-actions">
<button type="button" className="btn primary" onClick={onDismiss}>
{variant === 'accept' ? t('disclaimer.accept') : t('disclaimer.close')}
</button>
</div>
</div>
)
}
+76 -16
View File
@@ -1,10 +1,14 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
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
@@ -28,8 +32,10 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
export default function SettingsForm({ logbookId }: SettingsFormProps) {
const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog()
const { restartTour } = useAppTour()
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
const [colorScheme, setColorScheme] = useState(localStorage.getItem('active_color_scheme') || 'auto')
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
@@ -203,6 +209,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.')
@@ -245,17 +252,29 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
}
}
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
localStorage.setItem('active_theme', nextTheme)
localStorage.setItem('active_color_scheme', nextColorScheme)
notifyAppearanceChanged()
}
const handleThemeChange = (nextTheme: string) => {
setTheme(nextTheme)
persistAppearance(nextTheme, colorScheme)
}
const handleColorSchemeChange = (nextColorScheme: string) => {
setColorScheme(nextColorScheme)
persistAppearance(theme, nextColorScheme)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setSuccess(false)
// Save to localStorage
localStorage.setItem('owm_api_key', apiKey.trim())
localStorage.setItem('active_theme', theme)
// Notify App of theme change
window.dispatchEvent(new Event('theme-changed'))
persistAppearance(theme, colorScheme)
setSaving(false)
setSuccess(true)
@@ -312,22 +331,63 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
</p>
<div className="input-group">
<select
<ThemedSelect
id="app-theme"
className="input-text"
value={theme}
onChange={(e) => setTheme(e.target.value)}
disabled={saving}
style={{ background: 'rgba(11, 12, 16, 0.85)', color: '#f1f5f9' }}
>
<option value="auto">{t('settings.theme_auto')}</option>
<option value="ocean">{t('settings.theme_ocean')}</option>
<option value="material">{t('settings.theme_material')}</option>
<option value="cupertino">{t('settings.theme_cupertino')}</option>
</select>
onChange={handleThemeChange}
options={[
{ value: 'auto', label: t('settings.theme_auto') },
{ value: 'ocean', label: t('settings.theme_ocean') },
{ value: 'material', label: t('settings.theme_material') },
{ value: 'cupertino', label: t('settings.theme_cupertino') }
]}
/>
</div>
</div>
<div className="member-editor-card glass mt-4">
<h3 style={{ marginTop: 0, marginBottom: '12px', color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.color_scheme_title')}
</h3>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.color_scheme_label')}
</p>
<div className="input-group">
<ThemedSelect
id="app-color-scheme"
value={colorScheme}
disabled={saving}
onChange={handleColorSchemeChange}
options={[
{ value: 'auto', label: t('settings.color_scheme_auto') },
{ value: 'light', label: t('settings.color_scheme_light') },
{ value: 'dark', label: t('settings.color_scheme_dark') }
]}
/>
</div>
</div>
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Compass size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.tour_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.tour_desc')}
</p>
<button
type="button"
className="btn secondary"
onClick={() => restartTour()}
>
{t('settings.tour_restart')}
</button>
</div>
<div className="form-actions mt-4 mb-6">
{success && (
<div className="success-toast">
+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>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
export interface ThemedSelectOption {
value: string
label: string
}
interface ThemedSelectProps {
id?: string
value: string
options: ThemedSelectOption[]
onChange: (value: string) => void
disabled?: boolean
}
export default function ThemedSelect({
id,
value,
options,
onChange,
disabled = false
}: ThemedSelectProps) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const selected = options.find((option) => option.value === value)
useEffect(() => {
if (!open) return
const closeOnOutsideClick = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', closeOnOutsideClick)
document.addEventListener('keydown', closeOnEscape)
return () => {
document.removeEventListener('mousedown', closeOnOutsideClick)
document.removeEventListener('keydown', closeOnEscape)
}
}, [open])
const selectOption = (nextValue: string) => {
onChange(nextValue)
setOpen(false)
}
return (
<div className={`themed-select${open ? ' is-open' : ''}`} ref={rootRef}>
<button
type="button"
id={id}
className="themed-select-trigger input-text"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => !disabled && setOpen((current) => !current)}
>
<span>{selected?.label ?? value}</span>
<ChevronDown size={16} className="themed-select-chevron" aria-hidden="true" />
</button>
{open && (
<ul className="themed-select-menu" role="listbox" aria-labelledby={id}>
{options.map((option) => (
<li
key={option.value}
role="option"
aria-selected={option.value === value}
className={`themed-select-option${option.value === value ? ' is-selected' : ''}`}
onClick={() => selectOption(option.value)}
>
{option.label}
</li>
))}
</ul>
)}
</div>
)
}
+101
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 {
@@ -13,9 +14,30 @@ interface VesselFormProps {
preloadedData?: any
}
function metricInputFromStored(value: unknown): string {
if (value == null || value === '') return ''
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'string') return value.trim()
return ''
}
function parseOptionalMetricMeters(input: string): number | undefined {
const trimmed = input.trim().replace(',', '.')
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error('invalid_metric')
}
return parsed
}
export default function VesselForm({ logbookId, readOnly = false, preloadedData }: VesselFormProps) {
const { t } = useTranslation()
const [name, setName] = useState('')
const [vesselType, setVesselType] = useState<'sailing' | 'motor' | ''>('')
const [lengthM, setLengthM] = useState('')
const [draftM, setDraftM] = useState('')
const [airDraftM, setAirDraftM] = useState('')
const [homePort, setHomePort] = useState('')
const [charterCompany, setCharterCompany] = useState('')
const [owner, setOwner] = useState('')
@@ -43,6 +65,10 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
try {
if (readOnly && preloadedData) {
setName(preloadedData.name || '')
setVesselType(preloadedData.vesselType || '')
setLengthM(metricInputFromStored(preloadedData.lengthM))
setDraftM(metricInputFromStored(preloadedData.draftM))
setAirDraftM(metricInputFromStored(preloadedData.airDraftM))
setHomePort(preloadedData.homePort || '')
setCharterCompany(preloadedData.charterCompany || '')
setOwner(preloadedData.owner || '')
@@ -64,6 +90,10 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
const decrypted = await decryptJson(local.encryptedData, local.iv, local.tag, masterKey)
if (decrypted) {
setName(decrypted.name || '')
setVesselType(decrypted.vesselType || '')
setLengthM(metricInputFromStored(decrypted.lengthM))
setDraftM(metricInputFromStored(decrypted.draftM))
setAirDraftM(metricInputFromStored(decrypted.airDraftM))
setHomePort(decrypted.homePort || '')
setCharterCompany(decrypted.charterCompany || '')
setOwner(decrypted.owner || '')
@@ -168,8 +198,25 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
let parsedLengthM: number | undefined
let parsedDraftM: number | undefined
let parsedAirDraftM: number | undefined
try {
parsedLengthM = parseOptionalMetricMeters(lengthM)
parsedDraftM = parseOptionalMetricMeters(draftM)
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
} catch {
setError(t('vessel.invalid_metric'))
setSaving(false)
return
}
const yachtData = {
name: name.trim(),
vesselType: vesselType || undefined,
lengthM: parsedLengthM,
draftM: parsedDraftM,
airDraftM: parsedAirDraftM,
homePort: homePort.trim(),
charterCompany: charterCompany.trim(),
owner: owner.trim(),
@@ -205,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
@@ -302,6 +350,59 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
/>
</div>
<div className="input-group">
<label>{t('vessel.type')}</label>
<select
className="input-text"
value={vesselType}
onChange={(e) => setVesselType(e.target.value as 'sailing' | 'motor' | '')}
disabled={saving || readOnly}
>
<option value="">{t('vessel.type_unset')}</option>
<option value="sailing">{t('vessel.type_sailing')}</option>
<option value="motor">{t('vessel.type_motor')}</option>
</select>
</div>
<div className="input-group">
<label>{t('vessel.length_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={lengthM}
onChange={(e) => setLengthM(e.target.value)}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.draft_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={draftM}
onChange={(e) => setDraftM(e.target.value)}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.air_draft_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={airDraftM}
onChange={(e) => setAirDraftM(e.target.value)}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.port')}</label>
<input
+249
View File
@@ -0,0 +1,249 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode
} from 'react'
import {
clearTourCompleted,
isTourCompleted,
markTourCompleted
} from '../services/appTourStorage.js'
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export type AppTab = 'vessel' | 'crew' | 'logs' | 'stats' | 'settings'
export type TourStepId =
| 'welcome'
| 'nav_logs'
| 'entry_list'
| 'entry_open'
| 'entry_track'
| 'nav_vessel'
| 'nav_crew'
| 'finish'
interface TourNavigation {
setActiveTab: (tab: AppTab) => void
setSelectedEntryId: (entryId: string | null) => void
}
interface AppTourContextValue {
isActive: boolean
currentStepId: TourStepId | null
currentStepIndex: number
totalSteps: number
startTour: (options?: { force?: boolean }) => void
stopTour: () => void
restartTour: () => void
nextStep: () => void
prevStep: () => void
skipTour: () => void
registerNavigation: (navigation: TourNavigation) => void
requestStartAfterLogin: () => void
}
const STEP_ORDER: TourStepId[] = [
'welcome',
'nav_logs',
'entry_list',
'entry_open',
'entry_track',
'nav_vessel',
'nav_crew',
'finish'
]
const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
nav_logs: '[data-tour="nav-logs"]',
entry_list: '[data-tour="entry-list"]',
entry_open: '[data-tour="entry-first"]',
entry_track: '[data-tour="entry-track"]',
nav_vessel: '[data-tour="nav-vessel"]',
nav_crew: '[data-tour="nav-crew"]'
}
const AppTourContext = createContext<AppTourContextValue | null>(null)
export function AppTourProvider({ children }: { children: ReactNode }) {
const [isActive, setIsActive] = useState(false)
const [stepIndex, setStepIndex] = useState(0)
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
const navigationRef = useRef<TourNavigation | null>(null)
const currentStepId = isActive ? STEP_ORDER[stepIndex] ?? null : null
const applyStepSideEffects = useCallback((stepId: TourStepId) => {
const nav = navigationRef.current
if (!nav) return
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
nav.setActiveTab('logs')
}
if (stepId === 'entry_open' || stepId === 'entry_track') {
const firstEntryId = getStoredDemoFirstEntryId()
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
}
if (stepId === 'nav_vessel') {
nav.setSelectedEntryId(null)
nav.setActiveTab('vessel')
}
if (stepId === 'nav_crew') {
nav.setSelectedEntryId(null)
nav.setActiveTab('crew')
}
}, [])
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
if (!stepId) return
const selector = TARGET_BY_STEP[stepId]
if (!selector) return
window.requestAnimationFrame(() => {
const el = document.querySelector(selector)
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
})
}, [])
const startTour = useCallback((options?: { force?: boolean }) => {
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!options?.force && isTourCompleted(userId)) return
setStepIndex(0)
setIsActive(true)
}, [])
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 = useCallback(() => {
dismissTour('skipped', stepIndex)
}, [dismissTour, stepIndex])
const skipTour = useCallback(() => {
dismissTour('skipped', stepIndex)
}, [dismissTour, stepIndex])
const nextStep = useCallback(() => {
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))
}, [])
useEffect(() => {
if (!isActive) return
const stepId = STEP_ORDER[stepIndex]
if (!stepId) return
applyStepSideEffects(stepId)
scrollToCurrentTarget(stepId)
}, [isActive, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
const restartTour = useCallback(() => {
const userId = localStorage.getItem('active_userid')
if (!userId) return
clearTourCompleted(userId)
startTour({ force: true })
}, [startTour])
const registerNavigation = useCallback((navigation: TourNavigation) => {
navigationRef.current = navigation
}, [])
const requestStartAfterLogin = useCallback(() => {
setPendingAfterLogin(true)
}, [])
useEffect(() => {
if (!pendingAfterLogin) return
const userId = localStorage.getItem('active_userid')
if (!userId || isTourCompleted(userId)) {
setPendingAfterLogin(false)
return
}
const timer = window.setTimeout(() => {
startTour({ force: true })
setPendingAfterLogin(false)
}, 400)
return () => window.clearTimeout(timer)
}, [pendingAfterLogin, startTour])
const value = useMemo<AppTourContextValue>(
() => ({
isActive,
currentStepId,
currentStepIndex: stepIndex,
totalSteps: STEP_ORDER.length,
startTour,
stopTour,
restartTour,
nextStep,
prevStep,
skipTour,
registerNavigation,
requestStartAfterLogin
}),
[
currentStepId,
isActive,
nextStep,
prevStep,
registerNavigation,
requestStartAfterLogin,
restartTour,
skipTour,
startTour,
stepIndex,
stopTour
]
)
return <AppTourContext.Provider value={value}>{children}</AppTourContext.Provider>
}
export function useAppTour(): AppTourContextValue {
const ctx = useContext(AppTourContext)
if (!ctx) {
throw new Error('useAppTour must be used within AppTourProvider')
}
return ctx
}
export function getTourStepCopy(
stepId: TourStepId,
t: (key: string) => string
): { title: string; body: string } {
return {
title: t(`tour.steps.${stepId}.title`),
body: t(`tour.steps.${stepId}.body`)
}
}
export function getTourTargetSelector(stepId: TourStepId | null): string | null {
if (!stepId) return null
return TARGET_BY_STEP[stepId] ?? null
}
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
return stepId === 'welcome' || stepId === 'finish'
}
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useRef } from 'react'
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 {
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
return Date.now() < suppressUntil
}
function suppressUpdatePrompt(durationMs = UPDATE_SUPPRESS_MS): void {
sessionStorage.setItem(UPDATE_SUPPRESS_KEY, String(Date.now() + durationMs))
}
function clearUpdateSuppression(): void {
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
}
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
const checkForUpdate = () => {
if (isUpdateSuppressed()) return
registration.update().catch(() => {})
}
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
checkForUpdate()
}
}
document.addEventListener('visibilitychange', onVisibilityChange)
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.clearInterval(intervalId)
}
}
export function usePwaUpdate() {
const cleanupRef = useRef<(() => void) | null>(null)
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker
} = useRegisterSW({
immediate: true,
onNeedReload() {
clearUpdateSuppression()
setNeedRefresh(false)
window.location.reload()
},
onNeedRefresh() {
if (isUpdateSuppressed()) return
setNeedRefresh(true)
},
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
if (!registration) return
if (isUpdateSuppressed() || !registration.waiting) {
setNeedRefresh(false)
}
cleanupRef.current?.()
cleanupRef.current = scheduleUpdateChecks(registration)
}
})
useEffect(() => {
if (isUpdateSuppressed()) {
setNeedRefresh(false)
}
return () => {
cleanupRef.current?.()
cleanupRef.current = null
}
}, [setNeedRefresh])
const updateApp = async () => {
setNeedRefresh(false)
suppressUpdatePrompt()
await updateServiceWorker(true)
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
window.setTimeout(() => {
window.location.reload()
}, UPDATE_RELOAD_FALLBACK_MS)
}
const dismissUpdate = () => {
setNeedRefresh(false)
suppressUpdatePrompt(UPDATE_DISMISS_SUPPRESS_MS)
}
return { needRefresh, updateApp, dismissUpdate }
}
+123 -4
View File
@@ -10,6 +10,7 @@
"crew": "Crew-Liste",
"deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge",
"stats": "Statistik",
"settings": "Einstellungen"
},
"auth": {
@@ -66,7 +67,11 @@
"platform_ios": "Installation über Safari",
"platform_android": "Installation über den Browser",
"platform_desktop": "Installation als Desktop-App",
"settings_section": "App-Installation"
"settings_section": "App-Installation",
"update_title": "Update verfügbar",
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
"update_now": "Jetzt aktualisieren",
"update_reloading": "Wird geladen…"
},
"sync": {
"status_synced": "Synchronisiert",
@@ -76,6 +81,14 @@
"vessel": {
"title": "Schiffs-Stammdaten",
"name": "Yachtname",
"type": "Yachttyp",
"type_unset": "— nicht angegeben —",
"type_sailing": "Segelyacht",
"type_motor": "Motoryacht",
"length_m": "Länge (m)",
"draft_m": "Tiefgang (m)",
"air_draft_m": "Höhe (m)",
"invalid_metric": "Ungültiger Zahlenwert — bitte Meter als Dezimalzahl eingeben (z. B. 12,5).",
"port": "Heimathafen",
"owner": "Eigner",
"charter": "Charterfirma",
@@ -147,8 +160,8 @@
"loading": "Journal wird geladen...",
"delete_entry": "Tag löschen",
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?",
"carry_over_tanks_title": "Tankstände übernehmen?",
"carry_over_tanks_confirm": "Morgenstände vom letzten Reisetag als Startwerte übernehmen?\n\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
"carry_over_tanks_yes": "Übernehmen",
"carry_over_tanks_no": "Mit 0 starten",
"event_title": "Chronologisches Ereignisprotokoll",
@@ -278,6 +291,11 @@
"theme_ocean": "Ocean (Glassmorphismus)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_title": "Erscheinungsbild",
"color_scheme_label": "Hell- oder Dunkelmodus (Standard: Systemeinstellung)",
"color_scheme_auto": "Automatisch (System)",
"color_scheme_light": "Hell",
"color_scheme_dark": "Dunkel",
"share_title": "Logbuch teilen (Schreibgeschützt)",
"share_desc": "Aktivieren Sie diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann Ihre Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
"share_enable": "Öffentlichen Link aktivieren",
@@ -291,7 +309,108 @@
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
"delete_account_confirm_no": "Abbrechen",
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"deleting_account": "Konto wird gelöscht…"
"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"
},
"disclaimer": {
"title": "Wichtige Hinweise",
"intro": "Bitte lesen Sie die folgenden Hinweise, bevor Sie Kapteins Daagbok nutzen.",
"e2e_title": "Ende-zu-Ende-Verschlüsselung",
"e2e_body": "Ihre Logbuchdaten werden Ende-zu-Ende verschlüsselt. Nur Sie bzw. Personen mit Ihrem Schlüssel können die Inhalte lesen. Auf dem Server werden ausschließlich verschlüsselte Daten gespeichert.",
"pwa_title": "Progressive Web App (PWA)",
"pwa_body": "Kapteins Daagbok läuft als Progressive Web App in Ihrem Browser und kann auf Ihrem Gerät installiert werden ähnlich wie eine native App, ohne App-Store.",
"storage_title": "Lokale Speicherung & Synchronisation",
"storage_body": "Ihre Daten werden lokal auf Ihrem Gerät zwischengespeichert (IndexedDB). Bei aktiver Internetverbindung werden Änderungen mit dem Server synchronisiert. Ohne Verbindung können Sie weiterarbeiten; die Synchronisation erfolgt später.",
"free_title": "Kostenlos & werbefrei",
"free_body": "Kapteins Daagbok ist kostenlos und enthält keine Werbung.",
"liability_title": "Haftungsausschluss",
"liability_body": "Die Nutzung erfolgt auf eigene Verantwortung. Es wird keine Haftung für Schäden übernommen, die aus der Nutzung der App entstehen einschließlich fehlerhafter oder unvollständiger Logbucheinträge, Datenverlust oder technischen Störungen.",
"warranty_title": "Keine Gewährleistung",
"warranty_body": "Es wird keine Gewährleistung für die Funktion, Richtigkeit oder Verfügbarkeit des Dienstes übernommen. Der Betrieb kann jederzeit unterbrochen, eingeschränkt oder eingestellt werden.",
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
"accept": "Akzeptieren und fortfahren",
"close": "Schließen",
"button_title": "Hinweise & Haftungsausschluss"
},
"demo": {
"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",
"next": "Weiter",
"finish": "Fertig",
"progress": "Schritt {{current}} von {{total}}",
"steps": {
"welcome": {
"title": "Willkommen an Bord!",
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen."
},
"nav_logs": {
"title": "Logbucheinträge",
"body": "Hier verwalten Sie Ihre Reisetage Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
},
"entry_list": {
"title": "Ihre Reisetage",
"body": "Jede Karte steht für einen Reisetag. Tippen Sie auf einen Eintrag, um Details zu sehen oder zu bearbeiten."
},
"entry_open": {
"title": "Reisetag öffnen",
"body": "So sieht ein ausgefüllter Logbucheintrag aus mit Events, Tankständen und mehr."
},
"entry_track": {
"title": "GPS-Track",
"body": "Laden Sie GPX-Dateien hoch oder sehen Sie bereits gespeicherte Routen auf der Karte inklusive Distanz und Geschwindigkeit."
},
"nav_vessel": {
"title": "Schiffsdaten",
"body": "Hinterlegen Sie Name, Maße und technische Daten Ihrer Yacht einmal ausfüllen, für alle Reisetage verfügbar."
},
"nav_crew": {
"title": "Crew-Liste",
"body": "Verwalten Sie Besatzungsmitglieder und weisen Sie sie später Reisetagen zu."
},
"finish": {
"title": "Alles klar!",
"body": "Sie können die Tour jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
}
}
}
}
}
+123 -4
View File
@@ -10,6 +10,7 @@
"crew": "Crew List",
"deviation": "Deviation Table",
"logs": "Logbook Entries",
"stats": "Statistics",
"settings": "Settings"
},
"auth": {
@@ -66,7 +67,11 @@
"platform_ios": "Install via Safari",
"platform_android": "Install via browser",
"platform_desktop": "Install as desktop app",
"settings_section": "App installation"
"settings_section": "App installation",
"update_title": "Update available",
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
"update_now": "Reload now",
"update_reloading": "Reloading…"
},
"sync": {
"status_synced": "Synced",
@@ -76,6 +81,14 @@
"vessel": {
"title": "Vessel Master Data",
"name": "Yacht Name",
"type": "Vessel Type",
"type_unset": "— not specified —",
"type_sailing": "Sailing yacht",
"type_motor": "Motor yacht",
"length_m": "Length (m)",
"draft_m": "Draft (m)",
"air_draft_m": "Air draft (m)",
"invalid_metric": "Invalid number — please enter meters as a decimal (e.g. 12.5).",
"port": "Home Port",
"owner": "Owner",
"charter": "Charter Company",
@@ -147,8 +160,8 @@
"loading": "Loading journal...",
"delete_entry": "Delete Day",
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
"carry_over_tanks_title": "Carry over tank levels?",
"carry_over_tanks_confirm": "Use the previous travel day's closing levels as morning levels?\n\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
"carry_over_tanks_title": "Carry over from previous day?",
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
"carry_over_tanks_yes": "Carry over",
"carry_over_tanks_no": "Start at 0",
"event_title": "Chronological Event Logbook",
@@ -278,6 +291,11 @@
"theme_ocean": "Ocean (Glassmorphism)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_title": "Appearance",
"color_scheme_label": "Light or dark mode (default: follow system)",
"color_scheme_auto": "Auto (System)",
"color_scheme_light": "Light",
"color_scheme_dark": "Dark",
"share_title": "Share Logbook (Read-Only)",
"share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).",
"share_enable": "Enable Public Link",
@@ -291,7 +309,108 @@
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
"delete_account_confirm_no": "Cancel",
"delete_account_failed": "Failed to delete account. Please try again.",
"deleting_account": "Deleting account…"
"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"
},
"disclaimer": {
"title": "Important notice",
"intro": "Please read the following information before using Kapteins Daagbok.",
"e2e_title": "End-to-end encryption",
"e2e_body": "Your logbook data is encrypted end-to-end. Only you or people with your key can read the contents. The server stores encrypted data only.",
"pwa_title": "Progressive Web App (PWA)",
"pwa_body": "Kapteins Daagbok runs as a Progressive Web App in your browser and can be installed on your device similar to a native app, without an app store.",
"storage_title": "Local storage & sync",
"storage_body": "Your data is cached locally on your device (IndexedDB). When online, changes are synced to the server. You can keep working offline; sync happens when connectivity returns.",
"free_title": "Free & ad-free",
"free_body": "Kapteins Daagbok is free to use and contains no advertising.",
"liability_title": "Disclaimer of liability",
"liability_body": "Use is at your own risk. No liability is accepted for damages arising from use of the app including incorrect or incomplete log entries, data loss, or technical failures.",
"warranty_title": "No warranty",
"warranty_body": "No warranty is provided for functionality, accuracy, or availability of the service. Operation may be interrupted, limited, or discontinued at any time.",
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
"accept": "Accept and continue",
"close": "Close",
"button_title": "Legal notice & disclaimer"
},
"demo": {
"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",
"next": "Next",
"finish": "Done",
"progress": "Step {{current}} of {{total}}",
"steps": {
"welcome": {
"title": "Welcome aboard!",
"body": "We created a demo logbook with three travel days in Kiel Bay. This short tour shows you the key features."
},
"nav_logs": {
"title": "Log entries",
"body": "Manage your travel days here departure, destination, weather, tank levels, and GPS tracks."
},
"entry_list": {
"title": "Your travel days",
"body": "Each card represents one travel day. Tap an entry to view or edit the details."
},
"entry_open": {
"title": "Open a travel day",
"body": "This is what a filled log entry looks like with events, tank levels, and more."
},
"entry_track": {
"title": "GPS track",
"body": "Upload GPX files or view saved routes on the map including distance and speed stats."
},
"nav_vessel": {
"title": "Vessel data",
"body": "Enter your yacht's name, dimensions, and technical details fill once, use on every travel day."
},
"nav_crew": {
"title": "Crew list",
"body": "Manage crew members and assign them to travel days later."
},
"finish": {
"title": "You're all set!",
"body": "You can restart the tour anytime in Settings. Fair winds!"
}
}
}
}
}
+4
View File
@@ -1,9 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'leaflet/dist/leaflet.css'
import './themes.css'
import './index.css'
import App from './App.tsx'
import './i18n'
import { applyAppearanceToDocument } from './services/appearance.ts'
applyAppearanceToDocument()
createRoot(document.getElementById('root')!).render(
<StrictMode>
+34
View File
@@ -0,0 +1,34 @@
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'
} 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)
}
+16
View File
@@ -0,0 +1,16 @@
export function getTourCompletedKey(userId: string): string {
return `app_tour_completed_${userId}`
}
export function isTourCompleted(userId: string | null): boolean {
if (!userId) return true
return localStorage.getItem(getTourCompletedKey(userId)) === '1'
}
export function markTourCompleted(userId: string): void {
localStorage.setItem(getTourCompletedKey(userId), '1')
}
export function clearTourCompleted(userId: string): void {
localStorage.removeItem(getTourCompletedKey(userId))
}
+53
View File
@@ -0,0 +1,53 @@
export type ColorSchemePreference = 'auto' | 'light' | 'dark'
export type ResolvedColorScheme = 'light' | 'dark'
export type AppTheme = 'ocean' | 'material' | 'cupertino'
const THEME_CLASSES = ['theme-ocean', 'theme-material', 'theme-cupertino'] as const
const SCHEME_CLASSES = ['scheme-light', 'scheme-dark'] as const
export function getColorSchemePreference(): ColorSchemePreference {
const stored = localStorage.getItem('active_color_scheme')
if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored
return 'auto'
}
export function resolveColorScheme(pref?: ColorSchemePreference): ResolvedColorScheme {
const preference = pref ?? getColorSchemePreference()
if (preference === 'light') return 'light'
if (preference === 'dark') return 'dark'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
export function resolveAppTheme(): AppTheme {
const configTheme = localStorage.getItem('active_theme') || 'auto'
if (configTheme === 'material' || configTheme === 'cupertino' || configTheme === 'ocean') {
return configTheme
}
const userAgent = navigator.userAgent || navigator.vendor || ''
if (/iPad|iPhone|iPod|Macintosh/.test(userAgent)) return 'cupertino'
if (/Android|Linux/.test(userAgent)) return 'material'
return 'ocean'
}
export function applyAppearanceToDocument(
theme: AppTheme = resolveAppTheme(),
scheme: ResolvedColorScheme = resolveColorScheme()
): void {
const root = document.documentElement
root.classList.remove(...THEME_CLASSES, ...SCHEME_CLASSES)
root.classList.add(`theme-${theme}`, `scheme-${scheme}`)
root.style.colorScheme = scheme
}
export function subscribeToSystemColorScheme(onChange: () => void): () => void {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => {
if (getColorSchemePreference() === 'auto') onChange()
}
media.addEventListener('change', handler)
return () => media.removeEventListener('change', handler)
}
export function notifyAppearanceChanged(): void {
window.dispatchEvent(new Event('appearance-changed'))
}
+4
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'
@@ -254,6 +255,8 @@ export async function registerUser(username: string): Promise<RegistrationResult
localStorage.setItem('active_username', username)
localStorage.setItem('active_userid', result.userId)
rememberUsername(username)
sessionStorage.setItem('seed_demo_logbook', '1')
trackPlausibleEvent(PlausibleEvents.ACCOUNT_CREATED)
}
return {
@@ -544,6 +547,7 @@ export async function deleteAccount(): Promise<boolean> {
// Wipe localStorage and session variables
logoutUser()
trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
return true
}
} catch (err) {
+24
View File
@@ -5,6 +5,8 @@ export interface LocalLogbook {
encryptedTitle: string
updatedAt: string
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
}
export interface LocalYacht {
@@ -120,6 +122,28 @@ class DaagboxDatabase extends Dexie {
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(4).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(5).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
}
}
+331
View File
@@ -0,0 +1,331 @@
import { createLogbook } from './logbook.js'
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { parseTrackFile } from './trackUpload.js'
import { syncLogbook } from './sync.js'
import { computeTrackStats } from '../utils/trackStats.js'
import i18n from '../i18n/index.js'
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
import dampSchleimuendeGpx from '../assets/demo/damp-schleimuende.gpx?raw'
export const SEED_DEMO_FLAG = 'seed_demo_logbook'
export function getDemoLogbookStorageKey(userId: string): string {
return `demo_logbook_id_${userId}`
}
export function getDemoFirstEntryStorageKey(userId: string): string {
return `demo_first_entry_id_${userId}`
}
interface DemoDaySpec {
date: string
dayOfTravel: string
departure: string
destination: string
gpx: string
filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
events: Array<Record<string, string>>
}
function buildDemoDays(): DemoDaySpec[] {
const isDe = i18n.language.startsWith('de')
return [
{
date: '2026-05-29',
dayOfTravel: '1',
departure: isDe ? 'Kiel' : 'Kiel',
destination: isDe ? 'Laboe' : 'Laboe',
gpx: kielLaboeGpx,
filename: 'kiel-laboe.gpx',
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
events: [
{
time: '10:15',
mgk: '042',
rwk: '038',
windDirection: isDe ? 'NW' : 'NW',
windStrength: '4 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Abfahrt Kiellinie' : 'Departure Kiellinie'
},
{
time: '11:20',
mgk: '030',
rwk: '028',
windDirection: 'N',
windStrength: '3 Bft',
seaState: isDe ? 'ruhig' : 'calm',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ankunft Laboe' : 'Arrival Laboe'
}
]
},
{
date: '2026-05-30',
dayOfTravel: '2',
departure: 'Laboe',
destination: 'Damp',
gpx: laboeDampGpx,
filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
events: [
{
time: '09:00',
mgk: '055',
rwk: '050',
windDirection: 'NE',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Auslaufen aus Laboe' : 'Departing Laboe'
},
{
time: '12:30',
mgk: '075',
rwk: '068',
windDirection: 'E',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Kurs entlang der Küste' : 'Coastal passage'
}
]
},
{
date: '2026-05-31',
dayOfTravel: '3',
departure: 'Damp',
destination: isDe ? 'Schleimünde' : 'Schleimünde',
gpx: dampSchleimuendeGpx,
filename: 'damp-schleimuende.gpx',
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
events: [
{
time: '08:30',
mgk: '290',
rwk: '285',
windDirection: 'W',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Passage zur Schlei' : 'Passage toward Schlei'
},
{
time: '14:00',
mgk: '310',
rwk: '305',
windDirection: 'NW',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ziel Schleimünde' : 'Destination Schleimünde'
}
]
}
]
}
async function putEncryptedRecord(
logbookId: string,
key: ArrayBuffer,
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
payloadId: string,
data: unknown,
now: string
): Promise<void> {
const encrypted = await encryptJson(data, key)
if (type === 'entry') {
await db.entries.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'crew') {
await db.crews.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'yacht') {
await db.yachts.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'gpsTrack') {
await db.gpsTracks.put({
entryId: payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
}
await db.syncQueue.put({
action: type === 'yacht' ? 'update' : 'create',
type,
payloadId: type === 'yacht' ? logbookId : payloadId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
const isDe = i18n.language.startsWith('de')
const yachtData = {
name: isDe ? 'Seeadler' : 'Seeadler',
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
lengthM: 12.5,
draftM: 1.9,
airDraftM: 18,
homePort: 'Kiel',
charterCompany: '',
owner: isDe ? 'Demo Skipper' : 'Demo Skipper',
registrationNumber: 'D-KI 1234',
callSign: 'DA1234',
atis: '',
mmsi: '',
sails: isDe
? ['Großsegel', 'Genua', 'Spinnaker']
: ['Mainsail', 'Genoa', 'Spinnaker'],
photo: null
}
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
const crewId = crypto.randomUUID()
const crewData = {
name: isDe ? 'Anna Müller' : 'Anna Müller',
address: isDe ? 'Hafenstraße 1, 24103 Kiel' : 'Harbour St 1, 24103 Kiel',
birthDate: '1988-04-12',
phone: '+49 431 123456',
nationality: isDe ? 'Deutsch' : 'German',
passportNumber: 'C01X00T47',
bloodType: 'A+',
allergies: '',
diseases: '',
role: 'crew',
photo: null
}
await putEncryptedRecord(logbookId, key, 'crew', crewId, crewData, now)
}
export interface DemoSeedResult {
logbookId: string
title: string
firstEntryId: string
}
export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null> {
const userId = localStorage.getItem('active_userid')
if (!userId || !getActiveMasterKey()) return null
const shouldSeed = sessionStorage.getItem(SEED_DEMO_FLAG) === '1'
const existingId = localStorage.getItem(getDemoLogbookStorageKey(userId))
if (existingId) {
const existing = await db.logbooks.get(existingId)
if (existing) {
if (shouldSeed) sessionStorage.removeItem(SEED_DEMO_FLAG)
const firstEntryId = localStorage.getItem(getDemoFirstEntryStorageKey(userId)) || ''
const title = i18n.t('demo.logbook_title')
return { logbookId: existingId, title, firstEntryId }
}
}
if (!shouldSeed) return null
sessionStorage.removeItem(SEED_DEMO_FLAG)
const title = i18n.t('demo.logbook_title')
const logbook = await createLogbook(title)
const logbookId = logbook.id
await db.logbooks.update(logbookId, { isDemo: 1 })
localStorage.setItem(getDemoLogbookStorageKey(userId), logbookId)
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not available for demo seed')
const now = new Date().toISOString()
await seedYachtAndCrew(logbookId, key, now)
const days = buildDemoDays()
let firstEntryId = ''
for (const day of days) {
const entryId = crypto.randomUUID()
if (!firstEntryId) firstEntryId = entryId
const { waypoints } = parseTrackFile(day.gpx, day.filename)
const stats = computeTrackStats(waypoints)
const entryPayload: Record<string, unknown> = {
date: day.date,
dayOfTravel: day.dayOfTravel,
departure: day.departure,
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
signSkipper: '',
signCrew: '',
events: day.events
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
await putEncryptedRecord(logbookId, key, 'entry', entryId, entryPayload, now)
const trackData = {
waypoints,
gpxContent: day.gpx,
filename: day.filename,
fileType: 'gpx'
}
await putEncryptedRecord(logbookId, key, 'gpsTrack', entryId, trackData, now)
}
localStorage.setItem(getDemoFirstEntryStorageKey(userId), firstEntryId)
syncLogbook(logbookId).catch((err) => console.warn('Demo logbook sync failed:', err))
return { logbookId, title, firstEntryId }
}
export function getStoredDemoLogbookId(): string | null {
const userId = localStorage.getItem('active_userid')
if (!userId) return null
return localStorage.getItem(getDemoLogbookStorageKey(userId))
}
export function getStoredDemoFirstEntryId(): string | null {
const userId = localStorage.getItem('active_userid')
if (!userId) return null
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
}
+13 -7
View File
@@ -2,6 +2,7 @@ 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'
@@ -11,6 +12,7 @@ export interface DecryptedLogbook {
updatedAt: string
isSynced: boolean
isShared: boolean
isDemo?: boolean
}
// Helper to decrypt a logbook's title using the active logbook key or master key
@@ -43,8 +45,6 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
throw new Error('Master key not found. User must log in.')
}
const sharedLogbookIds = new Set<string>()
if (navigator.onLine) {
try {
const response = await fetch(API_BASE, {
@@ -61,7 +61,6 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
// Decrypt and save logbook keys locally if they exist
for (const lb of serverLogbooks) {
const isShared = lb.userId !== userId
if (isShared) sharedLogbookIds.add(lb.id)
const encryptedKeyStr = isShared
? lb.collaborators?.[0]?.encryptedLogbookKey
@@ -101,11 +100,14 @@ 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
isSynced: 1,
isShared: lb.userId !== userId ? 1 : 0,
isDemo: localById.get(lb.id)?.isDemo
}))
// Clear existing cache for this user and insert new ones
@@ -128,7 +130,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
title,
updatedAt: lb.updatedAt,
isSynced: lb.isSynced === 1,
isShared: sharedLogbookIds.has(lb.id)
isShared: lb.isShared === 1,
isDemo: lb.isDemo === 1
})
}
@@ -195,7 +198,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
id: serverLb.id,
encryptedTitle: serverLb.encryptedTitle,
updatedAt: serverLb.updatedAt,
isSynced: 1
isSynced: 1,
isShared: 0
})
return {
@@ -216,7 +220,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
id: localId,
encryptedTitle: encryptedTitleStr,
updatedAt: now,
isSynced: 0
isSynced: 0,
isShared: 0
})
await db.syncQueue.put({
@@ -299,4 +304,5 @@ export async function deleteLogbook(id: string): Promise<void> {
// Perform local cascading cleanup
await deleteLocalLogbookCache(id)
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
}
+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)
}
+86 -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,63 @@ 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))
}
// Keep only the latest queue entry per entity (highest auto-increment id = most recent action).
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 latest = latestQueueItem(group)
kept.push(latest)
for (const item of group) {
if (item.id !== undefined && item.id !== latest.id) {
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 +104,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 +125,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 +333,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 +377,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)
+398
View File
@@ -0,0 +1,398 @@
/**
* Appearance tokens: scheme (light/dark) × theme (ocean/material/cupertino)
* Applied on document.documentElement via appearance.ts
*/
/* Fallback before JS hydrates (ocean · dark) */
html {
color-scheme: dark;
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
--app-text: #f1f5f9;
--app-text-heading: #f8fafc;
--app-text-muted: #94a3b8;
--app-text-subtle: #64748b;
--app-surface: rgba(11, 12, 16, 0.75);
--app-surface-alt: rgba(11, 12, 16, 0.6);
--app-surface-hover: rgba(11, 12, 16, 0.85);
--app-surface-inset: rgba(255, 255, 255, 0.02);
--app-border: rgba(212, 175, 55, 0.25);
--app-border-subtle: rgba(255, 255, 255, 0.08);
--app-border-muted: rgba(212, 175, 55, 0.15);
--app-input-bg: rgba(11, 12, 16, 0.85);
--app-input-bg-focus: #0b0c10;
--app-input-border: rgba(148, 163, 184, 0.25);
--app-input-text: #f1f5f9;
--app-accent: #d97706;
--app-accent-light: #fbbf24;
--app-accent-gradient: linear-gradient(135deg, #fef08a 0%, #d97706 100%);
--app-accent-bg: rgba(217, 119, 6, 0.1);
--app-accent-border: rgba(217, 119, 6, 0.2);
--app-accent-focus-ring: rgba(217, 119, 6, 0.2);
--app-btn-primary-text: #0b0c10;
--app-btn-secondary-bg: rgba(255, 255, 255, 0.05);
--app-btn-secondary-border: rgba(255, 255, 255, 0.12);
--app-btn-secondary-text: #e2e8f0;
--app-btn-secondary-hover-bg: rgba(255, 255, 255, 0.08);
--app-icon-btn-bg: rgba(255, 255, 255, 0.05);
--app-icon-btn-border: rgba(255, 255, 255, 0.1);
--app-divider: rgba(255, 255, 255, 0.06);
--app-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
--app-card-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
--app-error-bg: rgba(244, 63, 94, 0.08);
--app-error-text: #fda4af;
--app-error-border: #f43f5e;
--app-warning-text: #f43f5e;
--app-warning-bg: rgba(244, 63, 94, 0.08);
--app-warning-border: rgba(244, 63, 94, 0.2);
--app-empty-border: rgba(255, 255, 255, 0.08);
--app-empty-bg: rgba(255, 255, 255, 0.02);
--app-sidebar-active-bg: rgba(217, 119, 6, 0.12);
--app-sidebar-active-border: #d97706;
--app-sidebar-active-text: #fbbf24;
--app-header-border: rgba(212, 175, 55, 0.15);
--app-table-border: rgba(255, 255, 255, 0.08);
--app-progress-bar: linear-gradient(90deg, #d97706, #fbbf24, #d97706);
--app-backdrop: blur(20px);
--app-radius-card: 16px;
--app-radius-input: 10px;
--app-radius-btn: 10px;
}
/* ===== OCEAN · DARK (default) ===== */
html.scheme-dark.theme-ocean {
color-scheme: dark;
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
--app-text: #f1f5f9;
--app-text-heading: #f8fafc;
--app-text-muted: #94a3b8;
--app-text-subtle: #64748b;
--app-surface: rgba(11, 12, 16, 0.75);
--app-surface-alt: rgba(11, 12, 16, 0.6);
--app-surface-hover: rgba(11, 12, 16, 0.85);
--app-surface-inset: rgba(255, 255, 255, 0.02);
--app-border: rgba(212, 175, 55, 0.25);
--app-border-subtle: rgba(255, 255, 255, 0.08);
--app-border-muted: rgba(212, 175, 55, 0.15);
--app-input-bg: rgba(11, 12, 16, 0.85);
--app-input-bg-focus: #0b0c10;
--app-input-border: rgba(148, 163, 184, 0.25);
--app-input-text: #f1f5f9;
--app-accent: #d97706;
--app-accent-light: #fbbf24;
--app-accent-gradient: linear-gradient(135deg, #fef08a 0%, #d97706 100%);
--app-accent-bg: rgba(217, 119, 6, 0.1);
--app-accent-border: rgba(217, 119, 6, 0.2);
--app-accent-focus-ring: rgba(217, 119, 6, 0.2);
--app-btn-primary-text: #0b0c10;
--app-btn-secondary-bg: rgba(255, 255, 255, 0.05);
--app-btn-secondary-border: rgba(255, 255, 255, 0.12);
--app-btn-secondary-text: #e2e8f0;
--app-btn-secondary-hover-bg: rgba(255, 255, 255, 0.08);
--app-icon-btn-bg: rgba(255, 255, 255, 0.05);
--app-icon-btn-border: rgba(255, 255, 255, 0.1);
--app-divider: rgba(255, 255, 255, 0.06);
--app-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
--app-card-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
--app-error-bg: rgba(244, 63, 94, 0.08);
--app-error-text: #fda4af;
--app-error-border: #f43f5e;
--app-warning-text: #f43f5e;
--app-warning-bg: rgba(244, 63, 94, 0.08);
--app-warning-border: rgba(244, 63, 94, 0.2);
--app-empty-border: rgba(255, 255, 255, 0.08);
--app-empty-bg: rgba(255, 255, 255, 0.02);
--app-sidebar-active-bg: rgba(217, 119, 6, 0.12);
--app-sidebar-active-border: #d97706;
--app-sidebar-active-text: #fbbf24;
--app-header-border: rgba(212, 175, 55, 0.15);
--app-table-border: rgba(255, 255, 255, 0.08);
--app-progress-bar: linear-gradient(90deg, #d97706, #fbbf24, #d97706);
--app-backdrop: blur(20px);
--app-radius-card: 16px;
--app-radius-input: 10px;
--app-radius-btn: 10px;
}
/* ===== OCEAN · LIGHT ===== */
html.scheme-light.theme-ocean {
color-scheme: light;
--app-body-bg: linear-gradient(165deg, #dbeafe 0%, #f8fafc 42%, #e2e8f0 100%);
--app-text: #1e293b;
--app-text-heading: #0f172a;
--app-text-muted: #475569;
--app-text-subtle: #64748b;
--app-surface: rgba(255, 255, 255, 0.88);
--app-surface-alt: rgba(255, 255, 255, 0.78);
--app-surface-hover: rgba(255, 255, 255, 0.96);
--app-surface-inset: rgba(15, 23, 42, 0.03);
--app-border: rgba(217, 119, 6, 0.28);
--app-border-subtle: rgba(15, 23, 42, 0.1);
--app-border-muted: rgba(217, 119, 6, 0.18);
--app-input-bg: #ffffff;
--app-input-bg-focus: #ffffff;
--app-input-border: rgba(100, 116, 139, 0.35);
--app-input-text: #0f172a;
--app-accent: #b45309;
--app-accent-light: #d97706;
--app-accent-gradient: linear-gradient(135deg, #fcd34d 0%, #b45309 100%);
--app-accent-bg: rgba(217, 119, 6, 0.12);
--app-accent-border: rgba(217, 119, 6, 0.25);
--app-accent-focus-ring: rgba(217, 119, 6, 0.25);
--app-btn-primary-text: #0b0c10;
--app-btn-secondary-bg: rgba(15, 23, 42, 0.04);
--app-btn-secondary-border: rgba(15, 23, 42, 0.12);
--app-btn-secondary-text: #334155;
--app-btn-secondary-hover-bg: rgba(15, 23, 42, 0.07);
--app-icon-btn-bg: rgba(15, 23, 42, 0.04);
--app-icon-btn-border: rgba(15, 23, 42, 0.1);
--app-divider: rgba(15, 23, 42, 0.08);
--app-shadow: 0 16px 40px rgba(15, 23, 42, 0.12), inset 0 0 0 1px rgba(255, 255, 255, 0.6);
--app-card-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
--app-error-bg: rgba(244, 63, 94, 0.08);
--app-error-text: #be123c;
--app-error-border: #e11d48;
--app-warning-text: #be123c;
--app-warning-bg: rgba(244, 63, 94, 0.06);
--app-warning-border: rgba(244, 63, 94, 0.2);
--app-empty-border: rgba(15, 23, 42, 0.12);
--app-empty-bg: rgba(15, 23, 42, 0.02);
--app-sidebar-active-bg: rgba(217, 119, 6, 0.1);
--app-sidebar-active-border: #d97706;
--app-sidebar-active-text: #b45309;
--app-header-border: rgba(217, 119, 6, 0.2);
--app-table-border: rgba(15, 23, 42, 0.1);
--app-progress-bar: linear-gradient(90deg, #d97706, #fbbf24, #d97706);
--app-backdrop: blur(20px);
--app-radius-card: 16px;
--app-radius-input: 10px;
--app-radius-btn: 10px;
}
/* ===== MATERIAL · DARK ===== */
html.scheme-dark.theme-material {
color-scheme: dark;
--app-body-bg: #121212;
--app-text: #f1f5f9;
--app-text-heading: #f8fafc;
--app-text-muted: #94a3b8;
--app-text-subtle: #64748b;
--app-surface: #1e1e1e;
--app-surface-alt: #1e1e1e;
--app-surface-hover: #252525;
--app-surface-inset: #2a2a2a;
--app-border: #2d2d2d;
--app-border-subtle: #2d2d2d;
--app-border-muted: #2d2d2d;
--app-input-bg: #2a2a2a;
--app-input-bg-focus: #2a2a2a;
--app-input-border: #3d3d3d;
--app-input-text: #f1f5f9;
--app-accent: #00adb5;
--app-accent-light: #00adb5;
--app-accent-gradient: linear-gradient(135deg, #00adb5 0%, #008f95 100%);
--app-accent-bg: rgba(0, 173, 181, 0.12);
--app-accent-border: rgba(0, 173, 181, 0.3);
--app-accent-focus-ring: rgba(0, 173, 181, 0.2);
--app-btn-primary-text: #ffffff;
--app-btn-secondary-bg: #2a2a2a;
--app-btn-secondary-border: #3d3d3d;
--app-btn-secondary-text: #f1f5f9;
--app-btn-secondary-hover-bg: #333333;
--app-icon-btn-bg: #2a2a2a;
--app-icon-btn-border: #3d3d3d;
--app-divider: #2d2d2d;
--app-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
--app-card-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
--app-error-bg: rgba(244, 63, 94, 0.08);
--app-error-text: #fda4af;
--app-error-border: #f43f5e;
--app-warning-text: #f43f5e;
--app-warning-bg: rgba(244, 63, 94, 0.08);
--app-warning-border: rgba(244, 63, 94, 0.2);
--app-empty-border: #2d2d2d;
--app-empty-bg: #1a1a1a;
--app-sidebar-active-bg: rgba(0, 173, 181, 0.08);
--app-sidebar-active-border: #00adb5;
--app-sidebar-active-text: #00adb5;
--app-header-border: #2d2d2d;
--app-table-border: #2d2d2d;
--app-progress-bar: linear-gradient(90deg, #00adb5, #008f95, #00adb5);
--app-backdrop: none;
--app-radius-card: 4px;
--app-radius-input: 4px;
--app-radius-btn: 4px;
}
/* ===== MATERIAL · LIGHT ===== */
html.scheme-light.theme-material {
color-scheme: light;
--app-body-bg: #fafafa;
--app-text: #212121;
--app-text-heading: #111827;
--app-text-muted: #616161;
--app-text-subtle: #757575;
--app-surface: #ffffff;
--app-surface-alt: #ffffff;
--app-surface-hover: #f5f5f5;
--app-surface-inset: #f5f5f5;
--app-border: #e0e0e0;
--app-border-subtle: #eeeeee;
--app-border-muted: #e0e0e0;
--app-input-bg: #ffffff;
--app-input-bg-focus: #ffffff;
--app-input-border: #bdbdbd;
--app-input-text: #212121;
--app-accent: #00838f;
--app-accent-light: #00838f;
--app-accent-gradient: linear-gradient(135deg, #00838f 0%, #006064 100%);
--app-accent-bg: rgba(0, 131, 143, 0.1);
--app-accent-border: rgba(0, 131, 143, 0.25);
--app-accent-focus-ring: rgba(0, 131, 143, 0.2);
--app-btn-primary-text: #ffffff;
--app-btn-secondary-bg: #f5f5f5;
--app-btn-secondary-border: #e0e0e0;
--app-btn-secondary-text: #424242;
--app-btn-secondary-hover-bg: #eeeeee;
--app-icon-btn-bg: #f5f5f5;
--app-icon-btn-border: #e0e0e0;
--app-divider: #e0e0e0;
--app-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
--app-card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--app-error-bg: rgba(244, 63, 94, 0.08);
--app-error-text: #be123c;
--app-error-border: #e11d48;
--app-warning-text: #be123c;
--app-warning-bg: rgba(244, 63, 94, 0.06);
--app-warning-border: rgba(244, 63, 94, 0.2);
--app-empty-border: #e0e0e0;
--app-empty-bg: #fafafa;
--app-sidebar-active-bg: rgba(0, 131, 143, 0.08);
--app-sidebar-active-border: #00838f;
--app-sidebar-active-text: #00838f;
--app-header-border: #e0e0e0;
--app-table-border: #e0e0e0;
--app-progress-bar: linear-gradient(90deg, #00838f, #00adb5, #00838f);
--app-backdrop: none;
--app-radius-card: 4px;
--app-radius-input: 4px;
--app-radius-btn: 4px;
}
/* ===== CUPERTINO · DARK ===== */
html.scheme-dark.theme-cupertino {
color-scheme: dark;
--app-body-bg: #000000;
--app-text: #ffffff;
--app-text-heading: #ffffff;
--app-text-muted: #aeaeb2;
--app-text-subtle: #8e8e93;
--app-surface: rgba(28, 28, 30, 0.72);
--app-surface-alt: rgba(28, 28, 30, 0.72);
--app-surface-hover: rgba(44, 44, 46, 0.85);
--app-surface-inset: rgba(255, 255, 255, 0.05);
--app-border: rgba(255, 255, 255, 0.1);
--app-border-subtle: rgba(255, 255, 255, 0.1);
--app-border-muted: rgba(255, 255, 255, 0.08);
--app-input-bg: rgba(255, 255, 255, 0.05);
--app-input-bg-focus: rgba(255, 255, 255, 0.07);
--app-input-border: rgba(255, 255, 255, 0.12);
--app-input-text: #ffffff;
--app-accent: #0a84ff;
--app-accent-light: #0a84ff;
--app-accent-gradient: linear-gradient(135deg, #0a84ff 0%, #007aff 100%);
--app-accent-bg: rgba(10, 132, 255, 0.12);
--app-accent-border: rgba(10, 132, 255, 0.3);
--app-accent-focus-ring: rgba(10, 132, 255, 0.25);
--app-btn-primary-text: #ffffff;
--app-btn-secondary-bg: rgba(255, 255, 255, 0.08);
--app-btn-secondary-border: rgba(255, 255, 255, 0.12);
--app-btn-secondary-text: #ffffff;
--app-btn-secondary-hover-bg: rgba(255, 255, 255, 0.12);
--app-icon-btn-bg: rgba(255, 255, 255, 0.08);
--app-icon-btn-border: rgba(255, 255, 255, 0.12);
--app-divider: rgba(255, 255, 255, 0.08);
--app-shadow: none;
--app-card-shadow: none;
--app-error-bg: rgba(255, 69, 58, 0.12);
--app-error-text: #ff6961;
--app-error-border: #ff453a;
--app-warning-text: #ff6961;
--app-warning-bg: rgba(255, 69, 58, 0.12);
--app-warning-border: rgba(255, 69, 58, 0.25);
--app-empty-border: rgba(255, 255, 255, 0.1);
--app-empty-bg: rgba(255, 255, 255, 0.04);
--app-sidebar-active-bg: rgba(10, 132, 255, 0.15);
--app-sidebar-active-border: #0a84ff;
--app-sidebar-active-text: #0a84ff;
--app-header-border: rgba(255, 255, 255, 0.1);
--app-table-border: rgba(255, 255, 255, 0.1);
--app-progress-bar: linear-gradient(90deg, #0a84ff, #007aff, #0a84ff);
--app-backdrop: blur(25px);
--app-radius-card: 12px;
--app-radius-input: 8px;
--app-radius-btn: 9999px;
}
/* ===== CUPERTINO · LIGHT ===== */
html.scheme-light.theme-cupertino {
color-scheme: light;
--app-body-bg: #f2f2f7;
--app-text: #1c1c1e;
--app-text-heading: #000000;
--app-text-muted: #636366;
--app-text-subtle: #8e8e93;
--app-surface: rgba(255, 255, 255, 0.82);
--app-surface-alt: rgba(255, 255, 255, 0.82);
--app-surface-hover: rgba(255, 255, 255, 0.95);
--app-surface-inset: rgba(0, 0, 0, 0.03);
--app-border: rgba(0, 0, 0, 0.08);
--app-border-subtle: rgba(0, 0, 0, 0.06);
--app-border-muted: rgba(0, 0, 0, 0.08);
--app-input-bg: #ffffff;
--app-input-bg-focus: #ffffff;
--app-input-border: rgba(0, 0, 0, 0.12);
--app-input-text: #1c1c1e;
--app-accent: #007aff;
--app-accent-light: #007aff;
--app-accent-gradient: linear-gradient(135deg, #007aff 0%, #0a84ff 100%);
--app-accent-bg: rgba(0, 122, 255, 0.1);
--app-accent-border: rgba(0, 122, 255, 0.25);
--app-accent-focus-ring: rgba(0, 122, 255, 0.2);
--app-btn-primary-text: #ffffff;
--app-btn-secondary-bg: rgba(0, 0, 0, 0.05);
--app-btn-secondary-border: rgba(0, 0, 0, 0.08);
--app-btn-secondary-text: #1c1c1e;
--app-btn-secondary-hover-bg: rgba(0, 0, 0, 0.08);
--app-icon-btn-bg: rgba(0, 0, 0, 0.05);
--app-icon-btn-border: rgba(0, 0, 0, 0.08);
--app-divider: rgba(0, 0, 0, 0.08);
--app-shadow: none;
--app-card-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
--app-error-bg: rgba(255, 59, 48, 0.1);
--app-error-text: #d70015;
--app-error-border: #ff3b30;
--app-warning-text: #d70015;
--app-warning-bg: rgba(255, 59, 48, 0.08);
--app-warning-border: rgba(255, 59, 48, 0.2);
--app-empty-border: rgba(0, 0, 0, 0.08);
--app-empty-bg: rgba(0, 0, 0, 0.02);
--app-sidebar-active-bg: rgba(0, 122, 255, 0.1);
--app-sidebar-active-border: #007aff;
--app-sidebar-active-text: #007aff;
--app-header-border: rgba(0, 0, 0, 0.08);
--app-table-border: rgba(0, 0, 0, 0.08);
--app-progress-bar: linear-gradient(90deg, #007aff, #0a84ff, #007aff);
--app-backdrop: blur(25px);
--app-radius-card: 12px;
--app-radius-input: 8px;
--app-radius-btn: 9999px;
}
/* Utility classes for inline-style migration */
.text-muted { color: var(--app-text-muted); }
.text-subtle { color: var(--app-text-subtle); }
.text-heading { color: var(--app-text-heading); }
html.scheme-light #root {
border-inline-color: var(--app-border-subtle);
}
+18
View File
@@ -41,6 +41,13 @@ export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
export interface LogEntryTankSource {
freshwater?: Partial<TankLevels>
fuel?: Partial<TankLevels>
destination?: string
}
export interface CarryOverFromPreviousDay {
freshwater: TankLevels
fuel: TankLevels
departure: string
}
export function emptyTankLevels(morning = 0): TankLevels {
@@ -62,3 +69,14 @@ export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankS
fuel: emptyTankLevels(getClosingTankLevel(previousEntry.fuel))
}
}
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
const departure = previousEntry?.destination?.trim() || ''
return { freshwater, fuel, departure }
}
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
return carryOver.freshwater.morning > 0 || carryOver.fuel.morning > 0 || carryOver.departure.length > 0
}
+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
}
+15 -1
View File
@@ -1,3 +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 {}
+5 -1
View File
@@ -38,8 +38,12 @@ export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
registerType: 'prompt',
includeAssets: ['favicon.ico', 'logo.png'],
workbox: {
cleanupOutdatedCaches: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}']
},
manifest: {
name: 'Kapteins Daagbok',
short_name: 'Daagbok',
+60
View File
@@ -0,0 +1,60 @@
# 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` |
## 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
## 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.
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Generates demo GPX tracks (LaboeDamp, DampSchleimünde) in Kapteins Daagbok format.
*/
import { writeFileSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const outDir = join(__dirname, '../client/src/assets/demo')
const NM_IN_METERS = 1852
function haversineMeters(lat1, lon1, lat2, lon2) {
const R = 6371000
const toRad = (d) => (d * Math.PI) / 180
const dLat = toRad(lat2 - lat1)
const dLon = toRad(lon2 - lon1)
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(a))
}
function bearingDeg(lat1, lon1, lat2, lon2) {
const toRad = (d) => (d * Math.PI) / 180
const toDeg = (r) => (r * 180) / Math.PI
const φ1 = toRad(lat1)
const φ2 = toRad(lat2)
const Δλ = toRad(lon2 - lon1)
const y = Math.sin(Δλ) * Math.cos(φ2)
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ)
return (toDeg(Math.atan2(y, x)) + 360) % 360
}
function generateTrack({ name, desc, start, end, distanceNm, startTime, avgSpeedKn = 4.5 }) {
const totalM = distanceNm * NM_IN_METERS
const numPoints = Math.max(40, Math.round(distanceNm * 25))
const course = bearingDeg(start.lat, start.lon, end.lat, end.lon)
const durationSec = (distanceNm / avgSpeedKn) * 3600
const startMs = new Date(startTime).getTime()
const points = []
for (let i = 0; i < numPoints; i++) {
const t = i / (numPoints - 1)
const lat = start.lat + (end.lat - start.lat) * t
const lon = start.lon + (end.lon - start.lon) * t
const ts = new Date(startMs + durationSec * t * 1000).toISOString()
const speedMs = (avgSpeedKn / 1.94384) * (0.85 + 0.3 * Math.sin(i * 0.4))
points.push({ lat, lon, ts, speedMs, course })
}
// Rescale last segment to hit target distance approximately
let acc = 0
for (let i = 1; i < points.length; i++) {
acc += haversineMeters(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon)
}
const scale = totalM / acc
const adjusted = [{ ...points[0] }]
for (let i = 1; i < points.length; i++) {
const prev = adjusted[i - 1]
const raw = points[i]
const seg = haversineMeters(prev.lat, prev.lon, raw.lat, raw.lon) * scale
const bearing = bearingDeg(prev.lat, prev.lon, raw.lat, raw.lon)
const R = 6371000
const br = (bearing * Math.PI) / 180
const lat1 = (prev.lat * Math.PI) / 180
const lon1 = (prev.lon * Math.PI) / 180
const lat2 = Math.asin(
Math.sin(lat1) * Math.cos(seg / R) + Math.cos(lat1) * Math.sin(seg / R) * Math.cos(br)
)
const lon2 =
lon1 +
Math.atan2(
Math.sin(br) * Math.sin(seg / R) * Math.cos(lat1),
Math.cos(seg / R) - Math.sin(lat1) * Math.sin(lat2)
)
adjusted.push({
lat: (lat2 * 180) / Math.PI,
lon: (lon2 * 180) / Math.PI,
ts: raw.ts,
speedMs: raw.speedMs,
course: raw.course
})
}
adjusted[adjusted.length - 1] = { ...adjusted.at(-1), lat: end.lat, lon: end.lon }
const trkpts = adjusted
.map(
(p) => ` <trkpt lat="${p.lat.toFixed(6)}" lon="${p.lon.toFixed(6)}">
<time>${p.ts}</time>
<ele>1.0</ele>
<speed>${p.speedMs.toFixed(3)}</speed>
<course>${p.course.toFixed(1)}</course>
</trkpt>`
)
.join('\n')
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Kapteins Daagbok Demo" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>${name}</name>
<desc>${desc}</desc>
<time>${startTime}</time>
</metadata>
<trk>
<name>${name}</name>
<type>sailing</type>
<trkseg>
${trkpts}
</trkseg>
</trk>
</gpx>
`
}
mkdirSync(outDir, { recursive: true })
const laboeDamp = generateTrack({
name: 'Laboe → Damp',
desc: 'Demo track Laboe to Damp, ~8 sm',
start: { lat: 54.397929, lon: 10.224316 },
end: { lat: 54.455, lon: 10.729 },
distanceNm: 8,
startTime: '2026-05-30T09:00:00Z',
avgSpeedKn: 4.2
})
const dampSchleimuende = generateTrack({
name: 'Damp → Schleimünde',
desc: 'Demo track Damp to Schleimünde, ~12 sm',
start: { lat: 54.455, lon: 10.729 },
end: { lat: 54.493, lon: 9.933 },
distanceNm: 12,
startTime: '2026-05-31T08:30:00Z',
avgSpeedKn: 4.8
})
writeFileSync(join(outDir, 'laboe-damp.gpx'), laboeDamp, 'utf8')
writeFileSync(join(outDir, 'damp-schleimuende.gpx'), dampSchleimuende, 'utf8')
console.log('Wrote laboe-damp.gpx and damp-schleimuende.gpx to', outDir)
+2
View File
@@ -61,6 +61,7 @@ async function getLogbookWithAccess(logbookId: string, userId: string) {
}
function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: string } | null }) {
// Intentional (HYBRID-ELECTRONIC-SIGNATURES.md §2.1): owner OR WRITE collaborator may sign entries.
return access.isOwner || access.collaboration?.role === 'WRITE'
}
@@ -106,6 +107,7 @@ async function isAuthorizedSigner(
role: 'skipper' | 'crew'
): Promise<boolean> {
if (role === 'skipper') {
// Skipper signing: owner or WRITE collaborator (design §2.1), using their own passkey.
if (signerUserId === ownerUserId) return true
const collaboration = await prisma.collaboration.findUnique({
where: {
+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 }
})