Compare commits

...

58 Commits

Author SHA1 Message Date
elpatron e2e038f2d6 chore: release v0.1.0.46 2026-05-31 11:30:47 +02:00
elpatron 634eb622fd fix(pwa): weiße Seite nach Android-Neustart ohne Master-Key vermeiden
Erzwingt Login wenn nur die HTTP-Session übrig ist, begrenzt SW-Reloads,
fängt Bootstrap-/Render-Fehler ab und stabilisiert den PWA-Kaltstart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:30:37 +02:00
elpatron 04b822b263 fix(logs): Wind- und Kurs-Dial auf gleiche Größe bringen
Der Wind-Dial nutzte size="sm" (220px), der Kurs-Dial md (260px).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:26:56 +02:00
elpatron ee60d5fda3 fix(dev): veralteten PWA-Cache bereinigen, damit i18n-Labels laden
Stale Service-Worker-Precache konnte Vite-Module und Locale-Bundles
überlagern, sodass Kompass-Dial-Texte als Roh-i18n-Keys erschienen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:25:10 +02:00
elpatron 3a7d244433 fix(logs): Kompass-Dial-Locales und UI-Labels vervollständigen
Ergänzt fehlende i18n-Keys (Himmelsrichtungen, Platzhalter, Schrittweite) und zeigt Validierungsfehler im Kurs-Dial.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:12:43 +02:00
elpatron 9e03fcda0a feat(logs): Kompass-Dial für Kurs- und Windeingabe
Ersetzt Textfelder für MgK, rwK und Wind durch einen mobilen Kompass-Ring, normalisiert Kurswinkel beim Speichern und führt Vitest mit Regressionstests für html lang ein.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:08:36 +02:00
elpatron 34c7d2d65c fix(logs): 24h-Uhrzeit per Dropdown und konsistentes html lang
Ersetzt natives type=time (AM/PM je nach System) durch Stunde/Minute-Auswahl, wandelt 12h-Werte beim Laden um und stellt html lang auf de/en zurück.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:01:03 +02:00
elpatron 658bc6c0c9 feat(logs): Ereignis-Uhrzeit vorbelegen und 24h-Format vereinheitlichen
Neue Ereignisse starten mit der aktuellen Uhrzeit; Datums-/Zeitanzeigen und Zeit-Picker nutzen durchgängig das 24-Stunden-Format.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 10:57:47 +02:00
elpatron dee2f7b95b chore: release v0.1.0.45 2026-05-31 10:50:13 +02:00
elpatron 4eaf5d7f30 fix(dashboard): Löschbutton und Badge auf Logbuch-Karten trennen
Aktions-Spalte im Flex-Layout statt absoluter Positionierung, mit responsivem Stacking auf schmalen Viewports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 10:49:55 +02:00
elpatron 257bca14d1 feat(dashboard): Logbuch-Titel per Inline-Bearbeitung umbenennen
Ersetzt Umbenennen-Button und Modal durch Klick auf den Kartentitel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 10:48:22 +02:00
elpatron 917fb92d85 feat: add logbook title editing with E2E encryption and sync support 2026-05-31 10:45:36 +02:00
elpatron b48b31580d chore: release v0.1.0.44 2026-05-31 10:08:13 +02:00
elpatron 7f0223c636 fix(profile): Abbrechen-Text im Recovery-Rotations-Dialog
Verwendet recovery_rotate_confirm_no statt remove_passkey_confirm_no.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 10:01:30 +02:00
elpatron 68af8c6361 fix(profile): Reauth für Passkey-Umbenennung und Geräte-Dialog
PATCH /credentials verlangt requireReauth wie add/delete; Client ruft
reauthWithPasskey vor rename auf. Abbrechen-Text beim Gerät vergessen korrigiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:59:44 +02:00
elpatron ad7e036ab7 feat(profile): Wiederherstellungsschlüssel rotieren
Neuer Recovery-Code über Profilseite mit Passkey-Reauth, Anzeige der
12 Wörter und API-Endpoint rotate-recovery; Plausible-Event dokumentiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:58:21 +02:00
elpatron 12c02f6392 fix(profile): eigene Fehlermeldung beim Passkey-Umbenennen
Verwendet profile.passkey_rename_failed statt add_passkey_failed,
damit Fehler beim Umbenennen nicht fälschlich als Hinzufügen angezeigt werden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:50:16 +02:00
elpatron 3698c6fbca feat(analytics): Plausible-Events für Profilseite
Trackt Profilaufruf, Passkey-/PIN-Aktionen und Gerät vergessen;
Dokumentation in docs/plausible-events.md ergänzt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:47:12 +02:00
elpatron d4538ec06e feat(profile): Passkey-Labels, Sicherheits-Checkliste und Geräte-Block
Erweitert die Profilseite um benennbare Passkeys, Sicherheitsübersicht,
Gerät/Sync-Status, Backup-Hinweis in der Gefahrenzone und Dialog beim
Löschen des letzten Passkeys.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:43:28 +02:00
elpatron 86cb4d92ec fix(profile): Logbuch-KPI und Statistik-Fallback robuster laden
Profil bleibt bei fehlenden Client-Stats sichtbar; logbookCount nutzt lokale logbooks.length mit Server-Fallback statt totem ?? in ungerenderter Sektion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:30:58 +02:00
elpatron b72b20b66c fix(dashboard): Profil-Button an btn-icon-Stil angleichen
Nutzt dieselbe 36px-Höhe, Farben und Hover wie die übrigen Header-Buttons; auf Mobile nur Icon in Kreisform.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:27:21 +02:00
elpatron 6ad75ff947 fix(auth): Add-credential-Challenges pro Versuch und single-use
Speichert Challenges nach challenge statt userId für parallele Flows und invalidiert sie vor der Verifikation, damit fehlgeschlagene Versuche keine Leaks hinterlassen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:25:02 +02:00
elpatron 75eba362d6 fix(profile): Linksbündiges Layout der Profilseite
Überschreibt die zentrierte #root-Textausrichtung für Identität, PIN-Formular und Header, damit Labels und Werte konsistent ausgerichtet sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:24:20 +02:00
elpatron afc5a1e200 feat(profile): Benutzerprofilseite mit Passkeys, PIN und Statistiken
Zentralisiert Account-Verwaltung vom Dashboard aus: Identität, Passkey-CRUD, lokaler PIN und KPIs; Kontolöschung wandert ausschließlich in die Profilseite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:19:56 +02:00
elpatron 79a54fdfc2 chore: release v0.1.0.43 2026-05-30 20:56:13 +02:00
elpatron e73c078463 fix(seo): replaceState nur bei abweichendem lng und Manifest auf Deutsch
Vermeidet unnötige History-Änderungen beim Seitenaufbau und stellt die
PWA-Beschreibung konsistent zu lang: 'de' bereit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:55:14 +02:00
elpatron 2eb6551200 chore: release v0.1.0.42 2026-05-30 20:53:29 +02:00
elpatron 9baaccf239 feat(settings): Warnhinweis zum privaten Teilen des Logbuch-Links
Nutzer sollen den Share-Link nur privat teilen, nicht in sozialen Medien.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:52:57 +02:00
elpatron df53420f3b feat(seo): Zweisprachige Meta-Tags und hreflang für DE/EN
SEO-Texte in i18n, dynamische Meta-Updates beim Sprachwechsel, hreflang-Links und ?lng-Parameter; PWA-Manifest zweisprachig.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:52:39 +02:00
elpatron 5271ed90c1 fix(marketing): Union-Jack-ClipPath im Beta-Flyer wiederherstellen
Der vorherige Fix hatte den SVG-Pfad durch fragmentierte Subpaths ersetzt; korrekter Pfad und PDF neu generiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:48:20 +02:00
elpatron a8ba998444 fix(marketing): Ungültigen Union-Jack-ClipPath im Beta-Flyer korrigieren
Der SVG-Pfad für die roten Diagonalen war syntaktisch fehlerhaft; PDF neu generiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:44:40 +02:00
elpatron 67d169080e docs(marketing): Landesflaggen für DE/EN im Beta-Flyer
Deutsch- und Englisch-Hinweis mit Inline-SVG-Flaggen und aktualisiertem PDF.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:39:44 +02:00
elpatron c67c1425df chore: release v0.1.0.41 2026-05-30 19:32:37 +02:00
elpatron d231a7fb40 feat(logs): Maschinenstunden pro Reisetag und Verbrauch pro Stunde
Maschinenstunden sind im Journal erfassbar; der Kraftstoffverbrauch pro Maschinenstunde wird aus Tagesverbrauch und Maschinenstunden berechnet und in Journal sowie Statistik als Read-only angezeigt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:29:38 +02:00
elpatron 4acb9b1290 fix(logs): Crew-Unterschrift mit Benutzerzuordnung und Owner-Crew-Signatur
Klassische Crew-Signaturen speichern Unterzeichner und Datum; Export und UI zeigen die Zuordnung. Eigner ohne WRITE-Collaborators dürfen wieder als Crew per Passkey signieren.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:24:46 +02:00
elpatron 4484724d38 fix(logs): Skipper- und Crew-Unterschrift rollenbasiert trennen
Jede Rolle darf nur das eigene Signaturfeld bearbeiten; Passkey-Freigabe auf dem Server entsprechend eingeschränkt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:21:51 +02:00
elpatron 5ea5111ec3 fix(auth): Schiffsdaten und Skipper-Profil nur für Logbuch-Eigner
Eingeladene Crew (WRITE) sieht Schiffsdaten und Skipper-Profil schreibgeschützt; Server-Sync lehnt entsprechende Änderungen ab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:17:45 +02:00
elpatron 7ab0ec6061 fix(logs): Ereignis-Bearbeitung sichern und Warnung bei ungespeicherten Änderungen
Normalisiert partielle Logbuch-Events beim Speichern (z. B. Besegelung) und warnt beim Verlassen von Editor, Tabs und Browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:15:49 +02:00
elpatron 258fee31ab fix(logs): Ereignisprotokoll chronologisch nach Uhrzeit sortieren
Einträge werden beim Laden, Speichern und Export älteste-oben angezeigt (sortLogEventsByTime).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:05:46 +02:00
elpatron 2e83f1c6bb fix(logs): Galerie-Upload für Foto-Anhänge auf Mobilgeräten ermöglichen
Entfernt capture="environment", damit Nutzer neben der Kamera auch Bilder aus der Gerätegalerie wählen können.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 18:59:48 +02:00
elpatron fcb76d1305 docs(marketing): Update beta flyer layout and content
Modified the screenshot layout to a three-column grid with adjusted gaps, reduced screenshot height for better fit, and refined feature descriptions for clarity. Added a new screenshot for vessel data. Updated the corresponding PDF to reflect these changes.
2026-05-30 18:22:09 +02:00
elpatron 7d96bbcfd8 docs(marketing): Beta-Flyer mit App-Screenshots und größerer Typografie
Zwei Screenshots nebeneinander, Schriftgrößen für bessere Seitennutzung auf A4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 17:22:52 +02:00
elpatron a586fcbfba fix(ui): autocomplete und Formulare für Passwort-Felder
PIN, Backup-Export/Import und API-Key entsprechen Chrome-DOM-Empfehlungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 16:36:38 +02:00
elpatron 0ed9ac6941 chore: release v0.1.0.40 2026-05-30 16:31:10 +02:00
elpatron b4fff04ee1 docs(marketing): Beta-Flyer-PDF neu generieren
Aktualisierte PDF-Version aus dem überarbeiteten HTML-Flyer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 16:30:55 +02:00
elpatron 7e01106801 fix(ui): Mobile-Layout für Tour, Header, Toolbars und Dialoge
Onboarding-Tooltip bleibt im Viewport; PWA-Banner während Tour aus.
Kopfzeilen, Listen-Toolbars, Link-Zeilen und Modals für iPhone optimiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 16:30:33 +02:00
elpatron caf6e395cd docs(marketing): Revise beta flyer feature descriptions for clarity and detail
Updated the feature descriptions to enhance clarity, including separating passwordless login and end-to-end encryption, and specifying GPS track upload with map representation. Added new feature for photo attachments for skipper and crew. Updated the corresponding PDF to reflect these changes.
2026-05-30 15:17:43 +02:00
elpatron a67575f4d2 chore: release v0.1.0.39 2026-05-30 15:10:26 +02:00
elpatron c2d620025e feat(ui): Beta-Badge in Login-, Dashboard- und Logbuch-Titelzeile
Wiederverwendbare BetaBadge-Komponente mit i18n-Tooltip.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 15:10:11 +02:00
elpatron 1524321afd docs(marketing): Update beta flyer feature description for passwordless login
Revised the feature description to specify "Passwortlose Passkey-Anmeldung" instead of "Passkey-Anmeldung" for clarity. Updated the corresponding PDF to reflect this change.
2026-05-30 14:58:40 +02:00
elpatron ab8a188fa0 chore: release v0.1.0.38 2026-05-30 14:49:49 +02:00
elpatron bb98af040e feat(analytics): Plausible-Events für öffentliche Logbuch-Freigabe
Trackt Aktivierung des Freigabelinks und erfolgreiches Öffnen unter /share.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:49:19 +02:00
elpatron 333c36db21 docs(marketing): Correct typo in beta flyer feature description
Updated the description in the beta flyer to correct the phrase "Crew und Schiffsdaten" to "Crew- und Schiffsdaten" for grammatical accuracy.
2026-05-30 14:36:31 +02:00
elpatron 3bd1970c59 docs(marketing): Update GPS feature description in beta flyer
Revised the GPS feature description from "GPS-Tracks" to "GPS-Track Upload" for improved clarity. Updated the corresponding PDF to reflect this change.
2026-05-30 14:35:58 +02:00
elpatron 75c1369c75 docs(marketing): Split PDF & CSV export and encryption features for clarity
Updated the beta flyer to separate the PDF & CSV export feature from the encrypted backup and recovery feature, enhancing clarity in the feature list.
2026-05-30 14:35:07 +02:00
elpatron 9ce1e384b7 docs(marketing): Update beta flyer feature list for improved detail
Enhanced the feature description to include 'Crew' in the nautical logbook format. Updated the PDF to reflect these changes.
2026-05-30 14:32:28 +02:00
elpatron 3eee42a30c docs(marketing): Beta-Flyer Feature-Liste erweitern
Neue Punkte für Teilen, mehrere Logbücher, Sprachen und Kiel-Herkunft;
PDF neu erzeugt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:31:52 +02:00
elpatron 90ffff0da6 fix(beta-flyer): update feature descriptions for clarity and accuracy
Revised the descriptions of offline capabilities and encryption methods in the beta flyer to enhance clarity. The PWA is now described as functioning on any smartphone and tablet, and the encryption method is specified as end-to-end.
2026-05-30 14:23:58 +02:00
62 changed files with 5509 additions and 401 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.38 0.1.0.47
+5 -2
View File
@@ -5,11 +5,14 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Kostenloses, werbefreies 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="description" content="Kostenloses, werbefreies 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, kostenlos, werbefrei, gratis, ohne Werbung" /> <meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung, yacht logbook, sailing log, ad-free" />
<meta name="author" content="Markus F.J. Busche" /> <meta name="author" content="Markus F.J. Busche" />
<meta name="robots" content="index, follow" /> <meta name="robots" content="index, follow" />
<meta name="application-name" content="Kapteins Daagbok" /> <meta name="application-name" content="Kapteins Daagbok" />
<link rel="canonical" href="https://kapteins-daagbok.eu/" /> <link rel="canonical" href="https://kapteins-daagbok.eu/" />
<link rel="alternate" hreflang="de" href="https://kapteins-daagbok.eu/?lng=de" />
<link rel="alternate" hreflang="en" href="https://kapteins-daagbok.eu/?lng=en" />
<link rel="alternate" hreflang="x-default" href="https://kapteins-daagbok.eu/" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-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-status-bar-style" content="black-translucent" />
@@ -33,7 +36,7 @@
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script> <script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
<title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title> <title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
</head> </head>
<body> <body style="margin:0;background:#0b0c10;color:#e2e8f0">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
+522 -1
View File
@@ -32,12 +32,14 @@
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0", "globals": "^17.6.0",
"happy-dom": "^20.9.0",
"playwright": "^1.51.0", "playwright": "^1.51.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.59.2", "typescript-eslint": "^8.59.2",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.1" "vite-plugin-pwa": "^1.0.1",
"vitest": "^3.2.4"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -2896,6 +2898,24 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/esrecurse": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -2991,6 +3011,23 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.60.0", "version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
@@ -3255,6 +3292,131 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3360,6 +3522,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/async": { "node_modules/async": {
"version": "3.2.6", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -3541,6 +3713,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -3642,6 +3824,33 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@@ -3848,6 +4057,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3979,6 +4198,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-abstract": { "node_modules/es-abstract": {
"version": "1.24.2", "version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -4068,6 +4300,13 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -4406,6 +4645,16 @@
"url": "https://github.com/bgub/eta?sponsor=1" "url": "https://github.com/bgub/eta?sponsor=1"
} }
}, },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4857,6 +5106,24 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/happy-dom": {
"version": "20.9.0",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz",
"integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.18.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/has-bigints": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -5710,6 +5977,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -6007,6 +6281,23 @@
"node": "20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/performance-now": { "node_modules/performance-now": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -6705,6 +6996,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -6773,6 +7071,13 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/stackblur-canvas": { "node_modules/stackblur-canvas": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
@@ -6783,6 +7088,13 @@
"node": ">=0.1.14" "node": ">=0.1.14"
} }
}, },
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/stop-iteration-iterator": { "node_modules/stop-iteration-iterator": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -6937,6 +7249,26 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strip-literal/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/supports-preserve-symlinks-flag": { "node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -7018,6 +7350,20 @@
"utrie": "^1.0.2" "utrie": "^1.0.2"
} }
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -7035,6 +7381,36 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tr46": { "node_modules/tr46": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
@@ -7439,6 +7815,29 @@
} }
} }
}, },
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-plugin-pwa": { "node_modules/vite-plugin-pwa": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz",
@@ -7470,6 +7869,79 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/debug": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/void-elements": { "node_modules/void-elements": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@@ -7486,6 +7958,16 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
@@ -7610,6 +8092,23 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -7855,6 +8354,28 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+4 -1
View File
@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run",
"preview": "vite preview", "preview": "vite preview",
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs", "generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
"generate:flyer:setup": "playwright install chromium" "generate:flyer:setup": "playwright install chromium"
@@ -36,12 +37,14 @@
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0", "globals": "^17.6.0",
"happy-dom": "^20.9.0",
"playwright": "^1.51.0", "playwright": "^1.51.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.59.2", "typescript-eslint": "^8.59.2",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.1" "vite-plugin-pwa": "^1.0.1",
"vitest": "^3.2.4"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
+891 -11
View File
File diff suppressed because it is too large Load Diff
+114 -29
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import './App.css' import './App.css'
import { DialogProvider } from './components/ModalDialog.tsx' import { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx' import AuthOnboarding from './components/AuthOnboarding.tsx'
import UserProfilePage from './components/UserProfilePage.tsx'
import LogbookDashboard from './components/LogbookDashboard.tsx' import LogbookDashboard from './components/LogbookDashboard.tsx'
import VesselForm from './components/VesselForm.tsx' import VesselForm from './components/VesselForm.tsx'
import CrewForm from './components/CrewForm.tsx' import CrewForm from './components/CrewForm.tsx'
@@ -13,7 +14,14 @@ import SettingsForm from './components/SettingsForm.tsx'
import InvitationAcceptance from './components/InvitationAcceptance.tsx' import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import AppTourOverlay from './components/AppTourOverlay.tsx' import AppTourOverlay from './components/AppTourOverlay.tsx'
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx' import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js' import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
import {
getActiveMasterKey,
logoutUser,
checkServerSession,
hasUnlockedLocalSession
} from './services/auth.js'
import AppErrorBoundary from './components/AppErrorBoundary.tsx'
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
import { import {
applyAppearanceToDocument, applyAppearanceToDocument,
@@ -28,6 +36,7 @@ import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx' import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
import AppFooter from './components/AppFooter.tsx' import AppFooter from './components/AppFooter.tsx'
import LogbookRoleBadge from './components/LogbookRoleBadge.tsx' import LogbookRoleBadge from './components/LogbookRoleBadge.tsx'
import BetaBadge from './components/BetaBadge.tsx'
import { db } from './services/db.js' import { db } from './services/db.js'
import { getLogbookAccess } from './services/logbookAccess.js' import { getLogbookAccess } from './services/logbookAccess.js'
import type { LogbookAccessRole } from './services/logbook.js' import type { LogbookAccessRole } from './services/logbook.js'
@@ -47,6 +56,7 @@ const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
function App() { function App() {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { confirmLeave } = useUnsavedChangesContext()
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour() const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false)
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null) const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
@@ -58,6 +68,7 @@ function App() {
const [online, setOnline] = useState(navigator.onLine) const [online, setOnline] = useState(navigator.onLine)
const [isSyncing, setIsSyncing] = useState(false) const [isSyncing, setIsSyncing] = useState(false)
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false) const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
const [showUserProfile, setShowUserProfile] = useState(false)
// Viewer mode for read-only shared links // Viewer mode for read-only shared links
const [isViewerMode, setIsViewerMode] = useState(false) const [isViewerMode, setIsViewerMode] = useState(false)
@@ -203,6 +214,52 @@ function App() {
} }
}, []) }, [])
const clearAuthenticatedAppState = useCallback(() => {
setIsAuthenticated(false)
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setShowUserProfile(false)
setTourSelectedEntryId(null)
setDemoHighlightEntryId(null)
}, [])
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
const enforceUnlockedSession = useCallback(() => {
if (isViewerMode || isDemoMode || isAcceptingInvite) return
if (isAuthenticated && !hasUnlockedLocalSession()) {
clearAuthenticatedAppState()
}
}, [
isAuthenticated,
isViewerMode,
isDemoMode,
isAcceptingInvite,
clearAuthenticatedAppState
])
useEffect(() => {
enforceUnlockedSession()
}, [enforceUnlockedSession])
useEffect(() => {
const onPageShow = (event: PageTransitionEvent) => {
if (event.persisted) {
enforceUnlockedSession()
}
}
const onVisibility = () => {
if (document.visibilityState === 'visible') {
enforceUnlockedSession()
}
}
window.addEventListener('pageshow', onPageShow)
document.addEventListener('visibilitychange', onVisibility)
return () => {
window.removeEventListener('pageshow', onPageShow)
document.removeEventListener('visibilitychange', onVisibility)
}
}, [enforceUnlockedSession])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -215,9 +272,7 @@ function App() {
localStorage.setItem('active_userid', session.userId) localStorage.setItem('active_userid', session.userId)
} }
const savedUser = localStorage.getItem('active_username') if (session.authenticated && hasUnlockedLocalSession()) {
const key = getActiveMasterKey()
if (session.authenticated && savedUser && key) {
setIsAuthenticated(true) setIsAuthenticated(true)
const savedLogbookId = localStorage.getItem('active_logbook_id') const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title') const savedLogbookTitle = localStorage.getItem('active_logbook_title')
@@ -225,6 +280,8 @@ function App() {
setActiveLogbookId(savedLogbookId) setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle) setActiveLogbookTitle(savedLogbookTitle)
} }
} else if (session.authenticated) {
clearAuthenticatedAppState()
} }
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
@@ -236,7 +293,7 @@ function App() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [clearAuthenticatedAppState])
useEffect(() => { useEffect(() => {
syncRouteFromLocation() syncRouteFromLocation()
@@ -346,18 +403,27 @@ function App() {
consumePendingPushLogbook() consumePendingPushLogbook()
} }
const handleLogout = () => { const handleTabChange = async (tab: AppTab) => {
if (tab === activeTab) return
if (!(await confirmLeave())) return
setActiveTab(tab)
}
const handleLogout = async () => {
if (!(await confirmLeave())) return
void logoutUser() void logoutUser()
setIsAuthenticated(false) setIsAuthenticated(false)
setActiveLogbookId(null) setActiveLogbookId(null)
setActiveLogbookTitle(null) setActiveLogbookTitle(null)
setShowUserProfile(false)
setTourSelectedEntryId(null) setTourSelectedEntryId(null)
setDemoHighlightEntryId(null) setDemoHighlightEntryId(null)
localStorage.removeItem('active_logbook_id') localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title') localStorage.removeItem('active_logbook_title')
} }
const handleBackToDashboard = () => { const handleBackToDashboard = async () => {
if (!(await confirmLeave())) return
setActiveLogbookId(null) setActiveLogbookId(null)
setActiveLogbookTitle(null) setActiveLogbookTitle(null)
setTourSelectedEntryId(null) setTourSelectedEntryId(null)
@@ -420,19 +486,29 @@ function App() {
) )
} }
const pwaInstallBanner = <PwaInstallPrompt variant="banner" /> const pwaInstallBanner = !isActive ? <PwaInstallPrompt variant="banner" /> : null
const logbookReadOnly = const logbookReadOnly =
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ' activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
if (!activeLogbookId) { if (!activeLogbookId) {
return ( return (
<div style={{ display: 'contents' }}> <div style={{ display: 'contents' }}>
{pwaInstallBanner} {pwaInstallBanner}
<LogbookDashboard {showUserProfile ? (
onSelectLogbook={selectLogbook} <UserProfilePage
onLogout={handleLogout} onBack={() => setShowUserProfile(false)}
/> onLogout={handleLogout}
/>
) : (
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
/>
)}
</div> </div>
) )
} }
@@ -445,13 +521,14 @@ function App() {
{/* Active Logbook Header */} {/* Active Logbook Header */}
<header className="app-header"> <header className="app-header">
<div className="app-header-left"> <div className="app-header-left">
<button className="btn-back" onClick={handleBackToDashboard}> <button className="btn-back" onClick={handleBackToDashboard} title={t('nav.dashboard')}>
<ChevronLeft size={16} /> <ChevronLeft size={16} />
{t('nav.dashboard')} <span className="hide-mobile">{t('nav.dashboard')}</span>
</button> </button>
<div className="app-title-area"> <div className="app-title-area">
<div className="app-title-row"> <div className="app-title-row">
<h2>{activeLogbookTitle}</h2> <h2>{activeLogbookTitle}</h2>
<BetaBadge />
{activeAccessRole && activeAccessRole !== 'OWNER' && ( {activeAccessRole && activeAccessRole !== 'OWNER' && (
<LogbookRoleBadge role={activeAccessRole} /> <LogbookRoleBadge role={activeAccessRole} />
)} )}
@@ -503,7 +580,7 @@ function App() {
<aside className="app-sidebar"> <aside className="app-sidebar">
<button <button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`} className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')} onClick={() => void handleTabChange('logs')}
data-tour="nav-logs" data-tour="nav-logs"
> >
<FileText size={18} /> <FileText size={18} />
@@ -512,7 +589,7 @@ function App() {
<button <button
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`} className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => setActiveTab('vessel')} onClick={() => void handleTabChange('vessel')}
data-tour="nav-vessel" data-tour="nav-vessel"
> >
<Ship size={18} /> <Ship size={18} />
@@ -521,7 +598,7 @@ function App() {
<button <button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`} className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')} onClick={() => void handleTabChange('crew')}
data-tour="nav-crew" data-tour="nav-crew"
> >
<Users size={18} /> <Users size={18} />
@@ -540,7 +617,7 @@ function App() {
<button <button
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`} className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')} onClick={() => void handleTabChange('stats')}
data-tour="nav-stats" data-tour="nav-stats"
> >
<BarChart2 size={18} /> <BarChart2 size={18} />
@@ -549,7 +626,7 @@ function App() {
<button <button
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`} className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
onClick={() => setActiveTab('settings')} onClick={() => void handleTabChange('settings')}
> >
<Settings size={18} /> <Settings size={18} />
{t('nav.settings')} {t('nav.settings')}
@@ -569,11 +646,15 @@ function App() {
)} )}
{activeTab === 'vessel' && ( {activeTab === 'vessel' && (
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly} /> <VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly || !isLogbookOwner} />
)} )}
{activeTab === 'crew' && ( {activeTab === 'crew' && (
<CrewForm logbookId={activeLogbookId} readOnly={logbookReadOnly} /> <CrewForm
logbookId={activeLogbookId}
readOnly={logbookReadOnly}
skipperReadOnly={!isLogbookOwner}
/>
)} )}
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && ( {activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
@@ -601,13 +682,17 @@ function App() {
export default function AppWrapper() { export default function AppWrapper() {
return ( return (
<DialogProvider> <AppErrorBoundary>
<AppTourProvider> <DialogProvider>
<PwaUpdatePrompt /> <UnsavedChangesProvider>
<App /> <AppTourProvider>
<AppTourOverlay /> <PwaUpdatePrompt />
</AppTourProvider> <App />
<AppFooter /> <AppTourOverlay />
</DialogProvider> </AppTourProvider>
<AppFooter />
</UnsavedChangesProvider>
</DialogProvider>
</AppErrorBoundary>
) )
} }
@@ -46,6 +46,7 @@ export default function AccountDangerZone({ className = '' }: AccountDangerZoneP
</div> </div>
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p> <p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
<p className="account-danger-zone__hint">{t('settings.delete_backup_hint')}</p>
<div className="form-actions account-danger-zone__actions"> <div className="form-actions account-danger-zone__actions">
<button <button
@@ -0,0 +1,42 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
interface Props {
children: ReactNode
}
interface State {
error: Error | null
}
export default class AppErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('Unhandled app error:', error, info.componentStack)
}
render() {
if (!this.state.error) {
return this.props.children
}
return (
<div className="auth-screen">
<div className="auth-card glass" role="alert">
<h2 style={{ marginTop: 0 }}>Kapteins Daagbok</h2>
<p style={{ color: 'var(--app-text-muted)', lineHeight: 1.5 }}>
Die App ist nach dem Neustart in einen fehlerhaften Zustand geraten. Bitte neu laden
oder die App vollständig beenden und erneut öffnen.
</p>
<button type="button" className="btn primary" style={{ width: '100%', marginTop: 16 }} onClick={() => window.location.reload()}>
Neu laden
</button>
</div>
</div>
)
}
}
+23 -6
View File
@@ -15,12 +15,33 @@ interface SpotlightRect {
height: number height: number
} }
const TOOLTIP_EDGE_MARGIN = 16
const TOOLTIP_ESTIMATED_HEIGHT = 240
function buildCutoutClipPath(rect: SpotlightRect): string { function buildCutoutClipPath(rect: SpotlightRect): string {
const right = rect.left + rect.width const right = rect.left + rect.width
const bottom = rect.top + rect.height 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)` 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)`
} }
function computeTooltipTop(spotlight: SpotlightRect): number {
const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
const below = spotlight.top + spotlight.height + 12
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
return below
}
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
if (above >= TOOLTIP_EDGE_MARGIN) {
return above
}
return Math.max(
TOOLTIP_EDGE_MARGIN,
Math.min(below, viewportBottom - TOOLTIP_ESTIMATED_HEIGHT)
)
}
export default function AppTourOverlay() { export default function AppTourOverlay() {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
@@ -111,12 +132,8 @@ export default function AppTourOverlay() {
const tooltipStyle = centered const tooltipStyle = centered
? undefined ? undefined
: spotlight : spotlight
? { ? { top: computeTooltipTop(spotlight) }
top: Math.min(window.innerHeight - 220, spotlight.top + spotlight.height + 12), : { top: '20%' }
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 const backdropStyle = spotlight && !centered
? { clipPath: buildCutoutClipPath(spotlight) } ? { clipPath: buildCutoutClipPath(spotlight) }
+9 -1
View File
@@ -13,6 +13,7 @@ import {
} from '../services/auth.js' } from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react' import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx' import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import BetaBadge from './BetaBadge.tsx'
interface AuthOnboardingProps { interface AuthOnboardingProps {
onAuthenticated: () => void onAuthenticated: () => void
@@ -272,6 +273,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</label> </label>
<input <input
type="password" type="password"
name="new-pin"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
maxLength={8} maxLength={8}
@@ -281,6 +283,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
onChange={(e) => setPinInput(e.target.value.replace(/\D/g, ''))} onChange={(e) => setPinInput(e.target.value.replace(/\D/g, ''))}
disabled={loading} disabled={loading}
required required
autoComplete="new-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }} style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/> />
</div> </div>
@@ -321,6 +324,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="input-group"> <div className="input-group">
<input <input
type="password" type="password"
name="pin"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
maxLength={8} maxLength={8}
@@ -330,6 +334,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
onChange={(e) => setPinLoginInput(e.target.value.replace(/\D/g, ''))} onChange={(e) => setPinLoginInput(e.target.value.replace(/\D/g, ''))}
disabled={loading} disabled={loading}
required required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }} style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/> />
</div> </div>
@@ -408,7 +413,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="auth-card glass"> <div className="auth-card glass">
<div className="auth-brand"> <div className="auth-brand">
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" /> <img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
<h1>{t('app.name')}</h1> <div className="auth-brand-title-row">
<h1>{t('app.name')}</h1>
<BetaBadge />
</div>
<p className="tagline">{t('auth.tagline')}</p> <p className="tagline">{t('auth.tagline')}</p>
</div> </div>
+19
View File
@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
interface BetaBadgeProps {
className?: string
}
export default function BetaBadge({ className = '' }: BetaBadgeProps) {
const { t } = useTranslation()
return (
<span
className={`beta-badge ${className}`.trim()}
title={t('app.beta_hint')}
aria-label={t('app.beta_hint')}
>
{t('app.beta')}
</span>
)
}
+285
View File
@@ -0,0 +1,285 @@
import { useCallback, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
type CourseOutputMode,
type CourseStep,
dialDegreesToStorageValue,
formatCourseAngle,
formatCourseDisplay,
isCardinalDirection,
loadCourseDialStep,
parseCourseAngle,
pointerAngleToDegrees,
resolveCourseOutputMode,
saveCourseDialStep,
snapDegrees,
valueToDialDegrees
} from '../utils/courseAngle.js'
interface CourseDialInputProps {
value: string
onChange: (value: string) => void
disabled?: boolean
step?: CourseStep
allowCardinal?: boolean
displayMode?: 'degrees' | 'cardinal' | 'auto'
size?: 'md' | 'sm'
'aria-label': string
id?: string
}
const TICK_DEGREES = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]
function polarPoint(degrees: number, radius: number): { x: number; y: number } {
const rad = (degrees * Math.PI) / 180
return {
x: 100 + Math.sin(rad) * radius,
y: 100 - Math.cos(rad) * radius
}
}
export default function CourseDialInput({
value,
onChange,
disabled = false,
step: stepProp,
allowCardinal = false,
displayMode = 'degrees',
size = 'md',
'aria-label': ariaLabel,
id: idProp
}: CourseDialInputProps) {
const { t } = useTranslation()
const generatedId = useId()
const inputId = idProp ?? `${generatedId}-input`
const svgRef = useRef<SVGSVGElement>(null)
const [step, setStep] = useState<CourseStep>(() => stepProp ?? loadCourseDialStep())
const [inputDraft, setInputDraft] = useState<string | null>(null)
const [inputError, setInputError] = useState<string | null>(null)
const [outputModeOverride, setOutputModeOverride] = useState<CourseOutputMode | null>(null)
const effectiveStep = stepProp ?? step
const outputMode =
outputModeOverride ??
resolveCourseOutputMode(value, displayMode, allowCardinal)
const dialDegrees = useMemo(
() => snapDegrees(valueToDialDegrees(value, allowCardinal), effectiveStep),
[value, allowCardinal, effectiveStep]
)
const centerLabel = useMemo(
() => formatCourseDisplay(value, allowCardinal),
[value, allowCardinal]
)
const tickLabel = useCallback(
(degrees: number) => {
if (degrees === 0) return t('logs.compass_n')
if (degrees === 90) return t('logs.compass_e')
if (degrees === 180) return t('logs.compass_s')
if (degrees === 270) return t('logs.compass_w')
return String(degrees).padStart(3, '0')
},
[t]
)
const applyDegrees = useCallback(
(degrees: number) => {
onChange(dialDegreesToStorageValue(degrees, outputMode, effectiveStep))
setInputDraft(null)
setInputError(null)
},
[onChange, outputMode, effectiveStep]
)
const updateFromPointer = useCallback(
(clientX: number, clientY: number) => {
const svg = svgRef.current
if (!svg || disabled) return
const rect = svg.getBoundingClientRect()
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const raw = pointerAngleToDegrees(clientX, clientY, cx, cy)
applyDegrees(raw)
},
[applyDegrees, disabled]
)
const handlePointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
if (disabled) return
e.preventDefault()
e.currentTarget.setPointerCapture(e.pointerId)
updateFromPointer(e.clientX, e.clientY)
}
const handlePointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return
updateFromPointer(e.clientX, e.clientY)
}
const handlePointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputDraft(e.target.value)
}
const commitInput = () => {
const draft = (inputDraft ?? value).trim()
setInputDraft(null)
if (!draft) {
onChange('')
setInputError(null)
return
}
if (allowCardinal && outputMode === 'cardinal' && isCardinalDirection(draft)) {
onChange(draft.toUpperCase())
setInputError(null)
return
}
const parsed = parseCourseAngle(draft)
if (parsed === null) {
setInputError(t('logs.course_invalid'))
return
}
onChange(formatCourseAngle(snapDegrees(parsed, effectiveStep)))
setInputError(null)
}
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
commitInput()
return
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
const base = parseCourseAngle(value) ?? dialDegrees
const delta = e.key === 'ArrowUp' ? effectiveStep : -effectiveStep
applyDegrees(base + delta)
}
}
const handleStepChange = (next: CourseStep) => {
if (stepProp !== undefined) return
setStep(next)
saveCourseDialStep(next)
const parsed = parseCourseAngle(value)
if (parsed !== null) {
onChange(formatCourseAngle(snapDegrees(parsed, next)))
}
}
const toggleOutputMode = () => {
const next: CourseOutputMode = outputMode === 'cardinal' ? 'degrees' : 'cardinal'
setOutputModeOverride(next)
const deg = valueToDialDegrees(value, allowCardinal)
onChange(dialDegreesToStorageValue(deg, next, effectiveStep))
}
const inputValue = inputDraft ?? value
const sliderNow = dialDegrees
return (
<div
className={`course-dial course-dial--${size}${disabled ? ' course-dial--disabled' : ''}`}
>
{!stepProp && (
<div className="course-dial__step-toolbar" role="group" aria-label={t('logs.course_dial_step_label')}>
{([1, 5, 10] as const).map((s) => (
<button
key={s}
type="button"
className={`course-dial__step-btn${effectiveStep === s ? ' is-active' : ''}`}
onClick={() => handleStepChange(s)}
disabled={disabled}
aria-pressed={effectiveStep === s}
>
{s === 1 ? t('logs.course_step_fine') : s === 5 ? t('logs.course_step_medium') : t('logs.course_step_coarse')}
</button>
))}
</div>
)}
<div
className="course-dial__ring-wrap"
role="slider"
aria-label={ariaLabel}
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={sliderNow}
aria-disabled={disabled}
>
<svg
ref={svgRef}
className="course-dial__svg"
viewBox="0 0 200 200"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<circle className="course-dial__track" cx="100" cy="100" r="88" />
{TICK_DEGREES.map((deg) => {
const inner = polarPoint(deg, 76)
const outer = polarPoint(deg, 88)
const label = polarPoint(deg, 64)
return (
<g key={deg}>
<line className="course-dial__tick" x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} />
<text className="course-dial__label" x={label.x} y={label.y} textAnchor="middle" dominantBaseline="middle">
{tickLabel(deg)}
</text>
</g>
)
})}
<g className="course-dial__needle" transform={`rotate(${dialDegrees} 100 100)`}>
<line x1="100" y1="100" x2="100" y2="28" />
<circle cx="100" cy="100" r="6" />
</g>
<text className="course-dial__center" x="100" y="100" textAnchor="middle" dominantBaseline="middle">
{centerLabel}
</text>
</svg>
</div>
<p className="course-dial__hint">{t('logs.course_dial_hint')}</p>
<input
id={inputId}
type="text"
inputMode="numeric"
className="input-text course-dial__input"
value={inputValue}
onChange={handleInputChange}
onBlur={commitInput}
onKeyDown={handleInputKeyDown}
disabled={disabled}
placeholder={
outputMode === 'cardinal'
? t('logs.course_placeholder_cardinal')
: t('logs.course_placeholder_degrees')
}
aria-label={ariaLabel}
aria-invalid={inputError ? true : undefined}
/>
{inputError && <p className="course-dial__error">{inputError}</p>}
{allowCardinal && displayMode === 'auto' && (
<button
type="button"
className="course-dial__mode-toggle"
onClick={toggleOutputMode}
disabled={disabled}
>
{outputMode === 'cardinal' ? t('logs.wind_mode_degrees') : t('logs.wind_mode_cardinal')}
</button>
)}
</div>
)
}
+26 -15
View File
@@ -12,6 +12,7 @@ import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide
interface CrewFormProps { interface CrewFormProps {
logbookId: string logbookId: string
readOnly?: boolean readOnly?: boolean
skipperReadOnly?: boolean
preloadedData?: any[] preloadedData?: any[]
} }
@@ -34,9 +35,15 @@ interface DecryptedCrew {
data: CrewMemberData data: CrewMemberData
} }
export default function CrewForm({ logbookId, readOnly = false, preloadedData }: CrewFormProps) { export default function CrewForm({
logbookId,
readOnly = false,
skipperReadOnly = false,
preloadedData
}: CrewFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { showConfirm } = useDialog() const { showConfirm } = useDialog()
const skipperFormReadOnly = readOnly || skipperReadOnly
// Skipper profile state // Skipper profile state
const [skipName, setSkipName] = useState('') const [skipName, setSkipName] = useState('')
@@ -192,7 +199,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
const handleSaveSkipper = async (e: React.FormEvent) => { const handleSaveSkipper = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (readOnly) return if (skipperFormReadOnly) return
setSavingSkipper(true) setSavingSkipper(true)
setError(null) setError(null)
setSkipperSuccess(false) setSkipperSuccess(false)
@@ -397,10 +404,14 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
{error && <div className="auth-error mb-4">{error}</div>} {error && <div className="auth-error mb-4">{error}</div>}
{skipperReadOnly && !readOnly && (
<p className="help-text mb-4">{t('crew.skipper_read_only_hint')}</p>
)}
<form onSubmit={handleSaveSkipper} className="vessel-form"> <form onSubmit={handleSaveSkipper} className="vessel-form">
<div className="form-grid"> <div className="form-grid">
<div className="vessel-photo-wrapper"> <div className="vessel-photo-wrapper">
<div className="vessel-photo-preview" onClick={readOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: readOnly ? 'default' : 'pointer' }}> <div className="vessel-photo-preview" onClick={skipperFormReadOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: skipperFormReadOnly ? 'default' : 'pointer' }}>
{skipPhoto ? ( {skipPhoto ? (
<img src={skipPhoto} alt={skipName || 'Skipper'} className="vessel-photo" /> <img src={skipPhoto} alt={skipName || 'Skipper'} className="vessel-photo" />
) : ( ) : (
@@ -408,7 +419,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
<User size={48} className="placeholder-icon" /> <User size={48} className="placeholder-icon" />
</div> </div>
)} )}
{!readOnly && ( {!skipperFormReadOnly && (
<div className="vessel-photo-overlay"> <div className="vessel-photo-overlay">
<Camera size={24} /> <Camera size={24} />
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span> <span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
@@ -416,7 +427,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
)} )}
</div> </div>
{!readOnly && ( {!skipperFormReadOnly && (
<div className="vessel-photo-actions"> <div className="vessel-photo-actions">
<button <button
type="button" type="button"
@@ -473,7 +484,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipName} value={skipName}
onChange={(e) => setSkipName(e.target.value)} onChange={(e) => setSkipName(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
required required
/> />
</div> </div>
@@ -485,7 +496,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipAddress} value={skipAddress}
onChange={(e) => setSkipAddress(e.target.value)} onChange={(e) => setSkipAddress(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -496,7 +507,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipBirthDate} value={skipBirthDate}
onChange={(e) => setSkipBirthDate(e.target.value)} onChange={(e) => setSkipBirthDate(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -507,7 +518,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipPhone} value={skipPhone}
onChange={(e) => setSkipPhone(e.target.value)} onChange={(e) => setSkipPhone(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -518,7 +529,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipNationality} value={skipNationality}
onChange={(e) => setSkipNationality(e.target.value)} onChange={(e) => setSkipNationality(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -529,7 +540,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipPassport} value={skipPassport}
onChange={(e) => setSkipPassport(e.target.value)} onChange={(e) => setSkipPassport(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -540,7 +551,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipBloodType} value={skipBloodType}
onChange={(e) => setSkipBloodType(e.target.value)} onChange={(e) => setSkipBloodType(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -551,7 +562,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipAllergies} value={skipAllergies}
onChange={(e) => setSkipAllergies(e.target.value)} onChange={(e) => setSkipAllergies(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -562,12 +573,12 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipDiseases} value={skipDiseases}
onChange={(e) => setSkipDiseases(e.target.value)} onChange={(e) => setSkipDiseases(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
</div> </div>
{!readOnly && ( {!skipperFormReadOnly && (
<div className="form-actions"> <div className="form-actions">
{skipperSuccess && ( {skipperSuccess && (
<div className="success-toast"> <div className="success-toast">
@@ -0,0 +1,58 @@
import { useId, useMemo } from 'react'
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
interface EventTimeInput24hProps {
value: string
onChange: (value: string) => void
disabled?: boolean
'aria-label'?: string
}
export default function EventTimeInput24h({
value,
onChange,
disabled = false,
'aria-label': ariaLabel
}: EventTimeInput24hProps) {
const baseId = useId()
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
return (
<div className="time-input-24h">
<select
id={`${baseId}-hours`}
className="input-text time-input-24h__select"
value={hours}
onChange={(e) => onChange(joinTimeHHMM(e.target.value, minutes))}
disabled={disabled}
aria-label={ariaLabel ? `${ariaLabel} (h)` : undefined}
>
{HOURS.map((hour) => (
<option key={hour} value={hour}>
{hour}
</option>
))}
</select>
<span className="time-input-24h__sep" aria-hidden="true">
:
</span>
<select
id={`${baseId}-minutes`}
className="input-text time-input-24h__select"
value={minutes}
onChange={(e) => onChange(joinTimeHHMM(hours, e.target.value))}
disabled={disabled}
aria-label={ariaLabel ? `${ariaLabel} (min)` : undefined}
>
{MINUTES.map((minute) => (
<option key={minute} value={minute}>
{minute}
</option>
))}
</select>
</div>
)
}
+3 -3
View File
@@ -372,7 +372,7 @@ export default function LogEntriesList({
<Calendar size={24} className="form-icon" /> <Calendar size={24} className="form-icon" />
<h2>{t('logs.title')}</h2> <h2>{t('logs.title')}</h2>
</div> </div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> <div className="section-toolbar">
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}> <button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
<Download size={16} /> <Download size={16} />
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span> <span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
@@ -384,9 +384,9 @@ export default function LogEntriesList({
</button> </button>
{!readOnly && ( {!readOnly && (
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}> <button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
<Plus size={16} /> <Plus size={16} />
{t('logs.new_entry')} <span className="hide-mobile">{t('logs.new_entry')}</span>
</button> </button>
)} )}
</div> </div>
+250 -104
View File
@@ -16,11 +16,16 @@ import {
fingerprintSignature, fingerprintSignature,
normalizedSerializedSignature, normalizedSerializedSignature,
isPasskeySignature, isPasskeySignature,
isClassicSignature,
createClassicSignature,
isSignatureValidForEntry, isSignatureValidForEntry,
hasAnySignature hasAnySignature
} from '../utils/signatures.js' } from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js' import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload, type LogEventPayload } from '../utils/logEntryPayload.js' import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
import EventTimeInput24h from './EventTimeInput24h.tsx'
import CourseDialInput from './CourseDialInput.tsx'
import { degreesToCardinal } from '../utils/courseAngle.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js' import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js' import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js' import { getLogbookAccess } from '../services/logbookAccess.js'
@@ -35,6 +40,8 @@ import {
type SavedTrack type SavedTrack
} from '../services/trackUpload.js' } from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js' import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
function emptyTankLevels() { function emptyTankLevels() {
return { morning: 0, refilled: 0, evening: 0, consumption: 0 } return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
@@ -46,6 +53,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
const trackDistance = decrypted.trackDistanceNm const trackDistance = decrypted.trackDistanceNm
const trackSpeedMax = decrypted.trackSpeedMaxKn const trackSpeedMax = decrypted.trackSpeedMaxKn
const trackSpeedAvg = decrypted.trackSpeedAvgKn const trackSpeedAvg = decrypted.trackSpeedAvgKn
const motorHoursRaw = decrypted.motorHours
const payload = buildLogEntryPayload({ const payload = buildLogEntryPayload({
date: String(decrypted.date || ''), date: String(decrypted.date || ''),
@@ -76,6 +84,10 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
trackSpeedAvg != null && trackSpeedAvg !== '' trackSpeedAvg != null && trackSpeedAvg !== ''
? parseFloat(String(trackSpeedAvg)) ? parseFloat(String(trackSpeedAvg))
: undefined, : undefined,
motorHours:
motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw))
: undefined,
events: (decrypted.events as LogEventPayload[]) || [] events: (decrypted.events as LogEventPayload[]) || []
}) })
@@ -137,7 +149,7 @@ export default function LogEntryEditor({
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('') const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('') const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
const [canSignSkipper, setCanSignSkipper] = useState(false) const [canSignSkipper, setCanSignSkipper] = useState(false)
const [hasWriteCollaborators, setHasWriteCollaborators] = useState(false) const [canSignCrew, setCanSignCrew] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine) const [isOnline, setIsOnline] = useState(navigator.onLine)
const [entryHash, setEntryHash] = useState('') const [entryHash, setEntryHash] = useState('')
@@ -146,11 +158,14 @@ export default function LogEntryEditor({
const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('') const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('')
const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('') const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('')
// Motor hours under engine propulsion (per travel day)
const [motorHours, setMotorHours] = useState('')
// Events list state // Events list state
const [events, setEvents] = useState<LogEvent[]>([]) const [events, setEvents] = useState<LogEvent[]>([])
// Add Event Form State // Add Event Form State
const [evTime, setEvTime] = useState('') const [evTime, setEvTime] = useState(() => currentLocalTimeHHMM())
const [evMgk, setEvMgk] = useState('') const [evMgk, setEvMgk] = useState('')
const [evRwk, setEvRwk] = useState('') const [evRwk, setEvRwk] = useState('')
const [evWindPressure, setEvWindPressure] = useState('') const [evWindPressure, setEvWindPressure] = useState('')
@@ -167,6 +182,7 @@ export default function LogEntryEditor({
const [evGpsLng, setEvGpsLng] = useState('') const [evGpsLng, setEvGpsLng] = useState('')
const [evRemarks, setEvRemarks] = useState('') const [evRemarks, setEvRemarks] = useState('')
const [evLocationName, setEvLocationName] = useState('') const [evLocationName, setEvLocationName] = useState('')
const [activeCourseTab, setActiveCourseTab] = useState<'mgk' | 'rwk'>('mgk')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -206,6 +222,11 @@ export default function LogEntryEditor({
if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') { if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') {
setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn)) setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn))
} }
if (entry?.motorHours != null && entry.motorHours !== '') {
setMotorHours(String(entry.motorHours))
} else {
setMotorHours('')
}
} }
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => { const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
@@ -229,16 +250,22 @@ export default function LogEntryEditor({
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined, trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined, trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined, trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
events: eventsOverride ?? events events: eventsOverride ?? events
}) })
}, [ }, [
date, dayOfTravel, departure, destination, date, dayOfTravel, departure, destination,
fwMorning, fwRefilled, fwEvening, fwConsumption, fwMorning, fwRefilled, fwEvening, fwConsumption,
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption, fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
events events
]) ])
const fuelPerMotorHour = useMemo(
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
[fuelConsumption, motorHours]
)
const currentFingerprint = useMemo(() => { const currentFingerprint = useMemo(() => {
const payload = buildPayloadForSigning() const payload = buildPayloadForSigning()
return JSON.stringify({ return JSON.stringify({
@@ -248,7 +275,60 @@ export default function LogEntryEditor({
}) })
}, [buildPayloadForSigning, signSkipper, signCrew]) }, [buildPayloadForSigning, signSkipper, signCrew])
const isDirty = savedFingerprint !== null && currentFingerprint !== savedFingerprint const buildEventFromForm = (): LogEvent =>
normalizeLogEvent({
time: evTime,
mgk: evMgk,
rwk: evRwk,
windPressure: evWindPressure,
windDirection: evWindDirection,
windStrength: evWindStrength,
seaState: evSeaState,
weatherIcon: evWeatherIcon,
current: evCurrent,
heel: evHeel,
sailsOrMotor: evSailsOrMotor,
logReading: evLogReading,
distance: evDistance,
gpsLat: evGpsLat,
gpsLng: evGpsLng,
remarks: evRemarks
})
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
if (editingEventIndex !== null) {
return sortLogEventsByTime(events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev)))
}
return sortLogEventsByTime([...events, eventData])
}
const hasPendingEventForm = useMemo(() => {
if (!evTime.trim()) return false
const draft = buildEventFromForm()
if (editingEventIndex !== null) {
const original = events[editingEventIndex]
return original ? !logEventsEqual(draft, original) : false
}
return true
}, [
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
evGpsLat, evGpsLng, evRemarks, editingEventIndex, events
])
const isDirty = savedFingerprint !== null && (
currentFingerprint !== savedFingerprint || hasPendingEventForm
)
const { confirmLeave } = useRegisterUnsavedChanges(
`log-entry-${entryId}`,
!readOnly && !loading && isDirty
)
const handleBack = async () => {
if (!(await confirmLeave())) return
onBack()
}
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => { const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
if (readOnly) return if (readOnly) return
@@ -308,8 +388,11 @@ export default function LogEntryEditor({
useEffect(() => { useEffect(() => {
getLogbookAccess(logbookId).then((access) => { getLogbookAccess(logbookId).then((access) => {
if (!access) return if (!access) return
setCanSignSkipper(access.isOwner || access.role === 'WRITE') setCanSignSkipper(access.isOwner)
setHasWriteCollaborators(access.writeCollaboratorCount > 0) setCanSignCrew(
access.role === 'WRITE' ||
(access.isOwner && access.writeCollaboratorCount === 0)
)
}) })
}, [logbookId]) }, [logbookId])
@@ -375,6 +458,7 @@ export default function LogEntryEditor({
const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash) const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash)
const handlePasskeySignSkipper = async () => { const handlePasskeySignSkipper = async () => {
if (!canSignSkipper) return
const confirmed = await confirmSignWarning() const confirmed = await confirmSignWarning()
if (!confirmed) return if (!confirmed) return
@@ -392,6 +476,7 @@ export default function LogEntryEditor({
} }
const handlePasskeySignCrew = async () => { const handlePasskeySignCrew = async () => {
if (!canSignCrew) return
const confirmed = await confirmSignWarning() const confirmed = await confirmSignWarning()
if (!confirmed) return if (!confirmed) return
@@ -483,7 +568,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '') setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '') setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
loadTrackStatsFromEntry(preloadedEntry) loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || []) setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry)) setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
return return
} }
@@ -516,7 +601,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '') setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '') setSignCrew(normalizeSignature(decrypted.signCrew) || '')
loadTrackStatsFromEntry(decrypted) loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || []) setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(decrypted)) setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
} }
} }
@@ -732,10 +817,7 @@ export default function LogEntryEditor({
// Calculate wind compass direction sector // Calculate wind compass direction sector
if (wind?.deg !== undefined) { if (wind?.deg !== undefined) {
const deg = wind.deg setEvWindDirection(degreesToCardinal(wind.deg))
const sectors = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
const index = Math.round(deg / 22.5) % 16
setEvWindDirection(sectors[index])
} }
if (data.weather && Array.isArray(data.weather) && data.weather[0]) { if (data.weather && Array.isArray(data.weather) && data.weather[0]) {
@@ -783,27 +865,8 @@ export default function LogEntryEditor({
return currentItems.includes(item.toLowerCase()) return currentItems.includes(item.toLowerCase())
} }
const buildEventFromForm = (): LogEvent => ({
time: evTime,
mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
})
const clearEventForm = () => { const clearEventForm = () => {
setEvTime('') setEvTime(currentLocalTimeHHMM())
setEvMgk('') setEvMgk('')
setEvRwk('') setEvRwk('')
setEvWindPressure('') setEvWindPressure('')
@@ -824,22 +887,23 @@ export default function LogEntryEditor({
} }
const fillEventForm = (ev: LogEvent) => { const fillEventForm = (ev: LogEvent) => {
setEvTime(ev.time) const normalized = normalizeLogEvent(ev)
setEvMgk(ev.mgk) setEvTime(normalized.time)
setEvRwk(ev.rwk) setEvMgk(normalized.mgk)
setEvWindPressure(ev.windPressure) setEvRwk(normalized.rwk)
setEvWindDirection(ev.windDirection) setEvWindPressure(normalized.windPressure)
setEvWindStrength(ev.windStrength) setEvWindDirection(normalized.windDirection)
setEvSeaState(ev.seaState) setEvWindStrength(normalized.windStrength)
setEvWeatherIcon(ev.weatherIcon) setEvSeaState(normalized.seaState)
setEvCurrent(ev.current) setEvWeatherIcon(normalized.weatherIcon)
setEvHeel(ev.heel) setEvCurrent(normalized.current)
setEvSailsOrMotor(ev.sailsOrMotor) setEvHeel(normalized.heel)
setEvLogReading(ev.logReading) setEvSailsOrMotor(normalized.sailsOrMotor)
setEvDistance(ev.distance) setEvLogReading(normalized.logReading)
setEvGpsLat(ev.gpsLat) setEvDistance(normalized.distance)
setEvGpsLng(ev.gpsLng) setEvGpsLat(normalized.gpsLat)
setEvRemarks(ev.remarks) setEvGpsLng(normalized.gpsLng)
setEvRemarks(normalized.remarks)
setEvLocationName('') setEvLocationName('')
} }
@@ -863,30 +927,28 @@ export default function LogEntryEditor({
const handleSaveEvent = async (e: React.FormEvent) => { const handleSaveEvent = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (readOnly || !evTime) return if (readOnly || !isValidTimeHHMM(evTime)) return
const eventData = buildEventFromForm() const eventData = buildEventFromForm()
let nextEvents: LogEvent[] const isEdit = editingEventIndex !== null
const hadSkipperSignature = isEdit && !!signSkipper
if (editingEventIndex !== null) { if (hadSkipperSignature) {
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange() markSkipperSignatureClearedForEventChange()
nextEvents = events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev)) }
const nextEvents = applyEventFormToEvents(eventData)
try {
await persistEntryToDb(nextEvents)
setEvents(nextEvents)
clearEventForm()
if (hadSkipperSignature) { if (hadSkipperSignature) {
void showAlertRef.current( void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'), t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title') t('logs.sign_cleared_skipper_re_sign_title')
) )
} }
} else {
nextEvents = [...events, eventData]
}
setEvents(nextEvents)
clearEventForm()
try {
await persistEntryToDb(nextEvents)
} catch (err: any) { } catch (err: any) {
console.error('Failed to auto-save event:', err) console.error('Failed to auto-save event:', err)
setError(err.message || 'Failed to save event.') setError(err.message || 'Failed to save event.')
@@ -935,13 +997,28 @@ export default function LogEntryEditor({
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (readOnly || !isDirty) return if (readOnly) return
let eventsToSave = events
if (hasPendingEventForm) {
const isEdit = editingEventIndex !== null
if (isEdit && signSkipper) {
markSkipperSignatureClearedForEventChange()
}
eventsToSave = applyEventFormToEvents(buildEventFromForm())
setEvents(eventsToSave)
clearEventForm()
} else if (!isDirty) {
return
}
setSaving(true) setSaving(true)
setError(null) setError(null)
setSuccess(false) setSuccess(false)
try { try {
await persistEntryToDb() await persistEntryToDb(eventsToSave)
setSuccess(true) setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
@@ -972,7 +1049,7 @@ export default function LogEntryEditor({
<div className="form-card" style={{ paddingBottom: '20px' }}> <div className="form-card" style={{ paddingBottom: '20px' }}>
<div className="section-title-bar"> <div className="section-title-bar">
<div className="section-title-left"> <div className="section-title-left">
<button className="btn-back" onClick={onBack} style={{ padding: '6px 12px' }}> <button className="btn-back" onClick={() => void handleBack()} style={{ padding: '6px 12px' }}>
<ChevronLeft size={16} /> <ChevronLeft size={16} />
{t('logs.back_to_list')} {t('logs.back_to_list')}
</button> </button>
@@ -992,7 +1069,7 @@ export default function LogEntryEditor({
style={{ width: 'auto', padding: '8px 16px' }} style={{ width: 'auto', padding: '8px 16px' }}
> >
<Download size={16} /> <Download size={16} />
<span>{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span> <span className="hide-mobile">{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -1053,6 +1130,20 @@ export default function LogEntryEditor({
disabled={saving || readOnly} disabled={saving || readOnly}
/> />
</div> </div>
<div className="input-group">
<label>{t('logs.motor_hours')}</label>
<input
type="number"
className="input-text"
value={motorHours}
onChange={(e) => setMotorHours(e.target.value)}
disabled={saving || readOnly}
min="0"
step="0.1"
placeholder="0"
/>
</div>
</div> </div>
</div> </div>
@@ -1163,6 +1254,22 @@ export default function LogEntryEditor({
aria-readonly="true" aria-readonly="true"
/> />
</div> </div>
<div className="input-group">
<label>{t('logs.fuel_per_motor_hour')}</label>
<input
type="text"
className="input-text consumption-value"
value={
fuelPerMotorHour != null
? `${formatFuelPerMotorHour(fuelPerMotorHour)} L/h`
: '—'
}
readOnly
tabIndex={-1}
aria-readonly="true"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1262,36 +1369,46 @@ export default function LogEntryEditor({
<Clock size={12} style={{ display: 'inline', marginRight: 4 }} /> <Clock size={12} style={{ display: 'inline', marginRight: 4 }} />
{t('logs.event_time')} * {t('logs.event_time')} *
</label> </label>
<input <EventTimeInput24h
type="time"
className="input-text"
value={evTime} value={evTime}
onChange={(e) => setEvTime(e.target.value)} onChange={setEvTime}
disabled={saving} disabled={saving}
aria-label={t('logs.event_time')}
/> />
</div> </div>
<div className="input-group"> <div className="input-group course-dial-section">
<label>{t('logs.event_mgk')}</label> <label>
<input <Compass size={12} style={{ display: 'inline', marginRight: 4 }} />
type="text" {t('logs.event_course_section')}
placeholder="e.g. 180" </label>
className="input-text" <div className="course-dial-tabs" role="tablist" aria-label={t('logs.event_course_section')}>
value={evMgk} <button
onChange={(e) => setEvMgk(e.target.value)} type="button"
disabled={saving} role="tab"
/> aria-selected={activeCourseTab === 'mgk'}
</div> className={`course-dial-tab${activeCourseTab === 'mgk' ? ' is-active' : ''}`}
onClick={() => setActiveCourseTab('mgk')}
<div className="input-group"> disabled={saving}
<label>{t('logs.event_rwk')}</label> >
<input {t('logs.course_tab_mgk')}
type="text" </button>
placeholder="e.g. 185" <button
className="input-text" type="button"
value={evRwk} role="tab"
onChange={(e) => setEvRwk(e.target.value)} aria-selected={activeCourseTab === 'rwk'}
className={`course-dial-tab${activeCourseTab === 'rwk' ? ' is-active' : ''}`}
onClick={() => setActiveCourseTab('rwk')}
disabled={saving}
>
{t('logs.course_tab_rwk')}
</button>
</div>
<CourseDialInput
value={activeCourseTab === 'mgk' ? evMgk : evRwk}
onChange={activeCourseTab === 'mgk' ? setEvMgk : setEvRwk}
disabled={saving} disabled={saving}
aria-label={activeCourseTab === 'mgk' ? t('logs.event_mgk') : t('logs.event_rwk')}
/> />
</div> </div>
@@ -1370,15 +1487,15 @@ export default function LogEntryEditor({
</div> </div>
<div className="form-grid mb-4"> <div className="form-grid mb-4">
<div className="input-group"> <div className="input-group course-dial-section">
<label>{t('logs.event_wind_direction')}</label> <label>{t('logs.event_wind_direction')}</label>
<input <CourseDialInput
type="text"
placeholder="e.g. NNE"
className="input-text"
value={evWindDirection} value={evWindDirection}
onChange={(e) => setEvWindDirection(e.target.value)} onChange={setEvWindDirection}
disabled={saving || weatherLoading} disabled={saving || weatherLoading}
allowCardinal
displayMode="auto"
aria-label={t('logs.event_wind_direction')}
/> />
</div> </div>
@@ -1505,7 +1622,7 @@ export default function LogEntryEditor({
type="button" type="button"
className="btn secondary" className="btn secondary"
onClick={handleSaveEvent} onClick={handleSaveEvent}
disabled={saving || !evTime} disabled={saving || !isValidTimeHHMM(evTime)}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }} style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
> >
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />} {editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
@@ -1567,15 +1684,16 @@ export default function LogEntryEditor({
)} )}
</span> </span>
</div> </div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> <div className="track-actions">
<button <button
type="button" type="button"
className="btn secondary" className="btn secondary"
onClick={() => downloadTrackFile(savedTrack)} onClick={() => downloadTrackFile(savedTrack)}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
title={t('logs.gps_tracking_btn_gpx')}
> >
<Download size={14} /> <Download size={14} />
{t('logs.gps_tracking_btn_gpx')} <span className="hide-mobile">{t('logs.gps_tracking_btn_gpx')}</span>
</button> </button>
{!readOnly && ( {!readOnly && (
<button <button
@@ -1583,9 +1701,10 @@ export default function LogEntryEditor({
className="btn secondary" className="btn secondary"
onClick={handleDeleteTrack} onClick={handleDeleteTrack}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
title={t('logs.gps_track_delete')}
> >
<Trash2 size={14} /> <Trash2 size={14} />
{t('logs.gps_track_delete')} <span className="hide-mobile">{t('logs.gps_track_delete')}</span>
</button> </button>
)} )}
</div> </div>
@@ -1646,13 +1765,40 @@ export default function LogEntryEditor({
disabled={saving} disabled={saving}
isOnline={isOnline} isOnline={isOnline}
canSignSkipper={canSignSkipper} canSignSkipper={canSignSkipper}
hasWriteCollaborators={hasWriteCollaborators} canSignCrew={canSignCrew}
signSkipper={signSkipper} signSkipper={signSkipper}
signCrew={signCrew} signCrew={signCrew}
skipperSignatureValid={skipperSignatureValid} skipperSignatureValid={skipperSignatureValid}
crewSignatureValid={crewSignatureValid} crewSignatureValid={crewSignatureValid}
onSignSkipperChange={setSignSkipper} onSignSkipperChange={(value) => {
onSignCrewChange={setSignCrew} if (canSignSkipper && !readOnly) setSignSkipper(value)
}}
onSignCrewChange={(value) => {
if (!canSignCrew || readOnly) return
if (!value) {
setSignCrew('')
return
}
if (isPasskeySignature(value) || isClassicSignature(value)) {
setSignCrew(value)
return
}
if (!canSignSkipper) {
const userId = localStorage.getItem('active_userid') || ''
const username = localStorage.getItem('active_username') || ''
if (userId && username) {
setSignCrew(createClassicSignature({
role: 'crew',
userId,
username,
signedAt: new Date().toISOString(),
payload: value
}))
return
}
}
setSignCrew(value)
}}
onPasskeySignSkipper={handlePasskeySignSkipper} onPasskeySignSkipper={handlePasskeySignSkipper}
onPasskeySignCrew={handlePasskeySignCrew} onPasskeySignCrew={handlePasskeySignCrew}
onBeforeSign={confirmSignWarning} onBeforeSign={confirmSignWarning}
+105 -86
View File
@@ -12,6 +12,7 @@ import {
type LogbookBackupPreview type LogbookBackupPreview
} from '../services/logbookBackup.js' } from '../services/logbookBackup.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
interface LogbookBackupPanelProps { interface LogbookBackupPanelProps {
logbookId: string logbookId: string
@@ -41,7 +42,7 @@ function mapBackupError(code: string, t: (key: string) => string): string {
} }
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) { export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) {
const { t } = useTranslation() const { t, i18n } = useTranslation()
const { showConfirm } = useDialog() const { showConfirm } = useDialog()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -58,6 +59,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
const handleExportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleExport()
}
const handleImportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleRestore()
}
const handleExport = async () => { const handleExport = async () => {
setError(null) setError(null)
setSuccess(null) setSuccess(null)
@@ -209,40 +220,45 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</h4> </h4>
<p className="text-muted backup-section-desc">{t('settings.backup_export_desc')}</p> <p className="text-muted backup-section-desc">{t('settings.backup_export_desc')}</p>
<div className="input-group"> <form onSubmit={handleExportSubmit} className="backup-export-form">
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label> <div className="input-group">
<input <label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
id="backup-export-passphrase" <input
type="password" id="backup-export-passphrase"
className="input-text" name="backup-export-passphrase"
value={exportPassphrase} type="password"
onChange={(e) => setExportPassphrase(e.target.value)} className="input-text"
placeholder={t('settings.backup_passphrase_placeholder')} value={exportPassphrase}
autoComplete="new-password" onChange={(e) => setExportPassphrase(e.target.value)}
disabled={exporting} placeholder={t('settings.backup_passphrase_placeholder')}
/> autoComplete="new-password"
</div> disabled={exporting}
<div className="input-group"> required
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label> />
<input </div>
id="backup-export-confirm" <div className="input-group">
type="password" <label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
className="input-text" <input
value={exportConfirm} id="backup-export-confirm"
onChange={(e) => setExportConfirm(e.target.value)} name="backup-export-confirm"
autoComplete="new-password" type="password"
disabled={exporting} className="input-text"
/> value={exportConfirm}
</div> onChange={(e) => setExportConfirm(e.target.value)}
<button autoComplete="new-password"
type="button" disabled={exporting}
className="btn primary" required
onClick={handleExport} />
disabled={exporting || !exportPassphrase || !exportConfirm} </div>
> <button
<Download size={16} /> type="submit"
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')} className="btn primary"
</button> disabled={exporting || !exportPassphrase || !exportConfirm}
>
<Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button>
</form>
</section> </section>
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading"> <section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
@@ -252,58 +268,61 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</h4> </h4>
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p> <p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
<div className="input-group"> <form onSubmit={handleImportSubmit} className="backup-import-form">
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label> <div className="input-group">
<input <label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
id="backup-import-file" <input
ref={fileInputRef} id="backup-import-file"
type="file" ref={fileInputRef}
accept=".daagbok.json,application/json" type="file"
className="input-text" accept=".daagbok.json,application/json"
onChange={handleFileChange} className="input-text"
disabled={importing} onChange={handleFileChange}
/> disabled={importing}
</div> />
</div>
{importFile && ( {importFile && (
<> <>
<div className="input-group"> <div className="input-group">
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label> <label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
<input <input
id="backup-import-passphrase" id="backup-import-passphrase"
type="password" name="backup-import-passphrase"
className="input-text" type="password"
value={importPassphrase} className="input-text"
onChange={(e) => { value={importPassphrase}
setImportPassphrase(e.target.value) onChange={(e) => {
setImportPreview(null) setImportPassphrase(e.target.value)
}} setImportPreview(null)
autoComplete="current-password" }}
disabled={importing} autoComplete="current-password"
/> disabled={importing}
</div> required
/>
</div>
<div className="backup-actions-row"> <div className="backup-actions-row">
<button <button
type="button" type="button"
className="btn secondary" className="btn secondary"
onClick={handlePreviewImport} onClick={handlePreviewImport}
disabled={previewing || importing || !importPassphrase} disabled={previewing || importing || !importPassphrase}
> >
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')} {previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button> </button>
<button <button
type="button" type="submit"
className="btn primary" className="btn primary"
onClick={() => handleRestore()} disabled={importing || !importPassphrase}
disabled={importing || !importPassphrase} >
> <Upload size={16} />
<Upload size={16} /> {importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')} </button>
</button> </div>
</div> </>
</> )}
)} </form>
{importPreview && ( {importPreview && (
<div className="backup-preview glass"> <div className="backup-preview glass">
@@ -316,7 +335,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</ul> </ul>
<p className="text-muted backup-preview-date"> <p className="text-muted backup-preview-date">
{t('settings.backup_exported_at', { {t('settings.backup_exported_at', {
date: new Date(importPreview.exportedAt).toLocaleString() date: formatAppDateTime(importPreview.exportedAt, i18n.language)
})} })}
</p> </p>
</div> </div>
+112 -26
View File
@@ -1,13 +1,13 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js' import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx' import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { logoutUser } from '../services/auth.js' import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx' 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 { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx' import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx' import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
@@ -15,13 +15,17 @@ import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
interface LogbookDashboardProps { interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void onSelectLogbook: (id: string, title: string) => void
onLogout: () => void onLogout: () => void
onOpenProfile: () => void
} }
export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookDashboardProps) { export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { showConfirm } = useDialog() const { showConfirm } = useDialog()
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([]) const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
const [newTitle, setNewTitle] = useState('') const [newTitle, setNewTitle] = useState('')
const [editingLogbookId, setEditingLogbookId] = useState<string | null>(null)
const [editingTitleDraft, setEditingTitleDraft] = useState('')
const titleInputRef = useRef<HTMLInputElement>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -98,6 +102,49 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
} }
} }
useEffect(() => {
if (editingLogbookId) {
titleInputRef.current?.focus()
titleInputRef.current?.select()
}
}, [editingLogbookId])
const startTitleEdit = (lb: DecryptedLogbook, e: React.MouseEvent) => {
e.stopPropagation()
setEditingLogbookId(lb.id)
setEditingTitleDraft(lb.title)
}
const cancelTitleEdit = () => {
setEditingLogbookId(null)
setEditingTitleDraft('')
}
const commitTitleEdit = async (id: string) => {
if (editingLogbookId !== id) return
const lb = logbooks.find((item) => item.id === id)
const trimmedTitle = editingTitleDraft.trim()
cancelTitleEdit()
if (!lb || !trimmedTitle || trimmedTitle === lb.title.trim()) return
setLoading(true)
setError(null)
try {
await updateLogbookTitle(id, trimmedTitle)
setLogbooks((prev) =>
prev.map((item) =>
item.id === id ? { ...item, title: trimmedTitle, updatedAt: new Date().toISOString() } : item
)
)
} catch (err: any) {
setError(err.message || 'Failed to update logbook title')
} finally {
setLoading(false)
}
}
const handleLogout = () => { const handleLogout = () => {
void logoutUser() void logoutUser()
onLogout() onLogout()
@@ -111,7 +158,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared) const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
const sharedLogbooks = logbooks.filter((lb) => lb.isShared) const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
const renderLogbookCard = (lb: DecryptedLogbook) => ( const renderLogbookCard = (lb: DecryptedLogbook) => {
const isEditingTitle = editingLogbookId === lb.id
return (
<div <div
key={lb.id} key={lb.id}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`} className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
@@ -123,7 +173,36 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<div className="card-info"> <div className="card-info">
<div className="card-title-row"> <div className="card-title-row">
<h3>{lb.title}</h3> {isEditingTitle ? (
<input
ref={titleInputRef}
type="text"
className="logbook-title-inline-edit input-text"
value={editingTitleDraft}
onChange={(e) => setEditingTitleDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void commitTitleEdit(lb.id)
} else if (e.key === 'Escape') {
e.preventDefault()
cancelTitleEdit()
}
}}
onBlur={() => void commitTitleEdit(lb.id)}
disabled={loading}
aria-label={t('dashboard.edit_title')}
/>
) : (
<h3
className={lb.isShared ? undefined : 'logbook-title-editable'}
onClick={lb.isShared ? undefined : (e) => startTitleEdit(lb, e)}
title={lb.isShared ? undefined : t('dashboard.edit_title')}
>
{lb.title}
</h3>
)}
<LogbookRoleBadge role={lb.accessRole} /> <LogbookRoleBadge role={lb.accessRole} />
</div> </div>
<div className="card-meta"> <div className="card-meta">
@@ -143,16 +222,22 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div> </div>
</div> </div>
<button {!lb.isShared && (
className="btn-delete" <div className="logbook-card-actions">
onClick={(e) => handleDelete(lb.id, e)} <button
title={t('dashboard.delete_btn')} type="button"
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }} className="btn-delete"
> onClick={(e) => handleDelete(lb.id, e)}
<Trash2 size={18} /> title={t('dashboard.delete_btn')}
</button> aria-label={t('dashboard.delete_btn')}
>
<Trash2 size={18} />
</button>
</div>
)}
</div> </div>
) )
}
const renderLogbookSection = ( const renderLogbookSection = (
title: string, title: string,
@@ -177,7 +262,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<div className="header-brand"> <div className="header-brand">
<Ship className="header-logo" size={32} /> <Ship className="header-logo" size={32} />
<div> <div>
<h1>{t('app.name')}</h1> <div className="header-brand-title-row">
<h1>{t('app.name')}</h1>
<BetaBadge />
</div>
<p className="subtitle">{t('app.tagline')}</p> <p className="subtitle">{t('app.tagline')}</p>
</div> </div>
</div> </div>
@@ -206,14 +294,16 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div> </div>
{/* Skipper profile */} {/* Skipper profile */}
<div <button
className="skipper-badge" type="button"
title={t('dashboard.logged_in_as', { name: username })} className="btn-icon skipper-badge"
aria-label={t('dashboard.logged_in_as', { name: username })} onClick={onOpenProfile}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
> >
<User size={16} aria-hidden="true" /> <User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span> <span className="skipper-badge__name">{username}</span>
</div> </button>
{/* Lang toggle */} {/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language"> <button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
@@ -285,10 +375,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
)} )}
</section> </section>
</main> </main>
<section className="dashboard-account-section" aria-label={t('settings.danger_zone_title')}>
<AccountDangerZone />
</section>
</div> </div>
) )
} }
+2 -3
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Fingerprint, Loader2, AlertTriangle } from 'lucide-react' import { Fingerprint, Loader2, AlertTriangle } from 'lucide-react'
import type { PasskeySignature } from '../types/signatures.js' import type { PasskeySignature } from '../types/signatures.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
interface PasskeySignButtonProps { interface PasskeySignButtonProps {
label: string label: string
@@ -42,9 +43,7 @@ export default function PasskeySignButton({
} }
} }
const formattedDate = signature const formattedDate = signature ? formatAppDateTime(signature.signedAt, i18n.language) : ''
? new Date(signature.signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
: ''
return ( return (
<div className="passkey-sign-block"> <div className="passkey-sign-block">
-1
View File
@@ -233,7 +233,6 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
capture="environment"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileChange} onChange={handleFileChange}
style={{ display: 'none' }} style={{ display: 'none' }}
+2
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { decryptJson } from '../services/crypto.js' import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx' import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx' import CrewForm from './CrewForm.tsx'
import LogEntriesList from './LogEntriesList.tsx' import LogEntriesList from './LogEntriesList.tsx'
@@ -124,6 +125,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
} }
} }
setGpsTracks(decGpsTracks) setGpsTracks(decGpsTracks)
trackPlausibleEvent(PlausibleEvents.PUBLIC_LINK_OPENED)
} catch (err: any) { } catch (err: any) {
console.error(err) console.error(err)
+10 -6
View File
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } 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 { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx' import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx' import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx' import PushNotificationSettings from './PushNotificationSettings.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
@@ -111,6 +110,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const logbookKey = await ensureLogbookKey(logbookId) const logbookKey = await ensureLogbookKey(logbookId)
const hexKey = bufferToHex(logbookKey) const hexKey = bufferToHex(logbookKey)
setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`) setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`)
trackPlausibleEvent(PlausibleEvents.LOGBOOK_SHARED)
showAlert('Public share link enabled!') showAlert('Public share link enabled!')
} else { } else {
setShareEnabled(false) setShareEnabled(false)
@@ -292,12 +292,14 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</label> </label>
<input <input
id="owm-api-key" id="owm-api-key"
name="owm-api-key"
type="password" type="password"
className="input-text" className="input-text"
placeholder="e.g. 8b6a7f...d8" placeholder="e.g. 8b6a7f...d8"
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
disabled={saving} disabled={saving}
autoComplete="off"
/> />
</div> </div>
</div> </div>
@@ -398,6 +400,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
{t('settings.share_desc')} {t('settings.share_desc')}
</p> </p>
<p className="signature-lock-notice" style={{ marginBottom: '16px' }}>
{t('settings.share_privacy_warning')}
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
<label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}> <label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}>
<input <input
@@ -413,7 +419,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div> </div>
{shareEnabled && shareLink && ( {shareEnabled && shareLink && (
<div className="input-group mb-4" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> <div className="input-group mb-4 copy-link-row">
<input <input
type="text" type="text"
readOnly readOnly
@@ -454,7 +460,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
{t('logs.invite_link_desc')} {t('logs.invite_link_desc')}
</p> </p>
<div className="form-actions" style={{ justifyContent: 'flex-start', gap: '12px', marginBottom: '20px' }}> <div className="form-actions form-actions--start" style={{ gap: '12px', marginBottom: '20px' }}>
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
@@ -468,7 +474,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div> </div>
{inviteLink && ( {inviteLink && (
<div className="input-group mb-6" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> <div className="input-group mb-6 copy-link-row">
<input <input
type="text" type="text"
readOnly readOnly
@@ -534,8 +540,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
)} )}
</div> </div>
)} )}
{/* Danger Zone / Account Deletion */}
<AccountDangerZone className="mt-6" />
</div> </div>
) )
} }
+26 -9
View File
@@ -4,7 +4,8 @@ import { Check } from 'lucide-react'
import SignaturePad from './SignaturePad.tsx' import SignaturePad from './SignaturePad.tsx'
import PasskeySignButton from './PasskeySignButton.tsx' import PasskeySignButton from './PasskeySignButton.tsx'
import type { PasskeySignature, SignatureValue } from '../types/signatures.js' import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
import { isPasskeySignature } from '../utils/signatures.js' import { isPasskeySignature, getSignaturePayload, getSignatureAttribution } from '../utils/signatures.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
type SignatureMode = 'passkey' | 'classic' type SignatureMode = 'passkey' | 'classic'
@@ -13,7 +14,7 @@ interface SignatureSectionProps {
disabled?: boolean disabled?: boolean
isOnline: boolean isOnline: boolean
canSignSkipper: boolean canSignSkipper: boolean
hasWriteCollaborators: boolean canSignCrew: boolean
signSkipper: SignatureValue | '' signSkipper: SignatureValue | ''
signCrew: SignatureValue | '' signCrew: SignatureValue | ''
skipperSignatureValid: boolean skipperSignatureValid: boolean
@@ -25,14 +26,28 @@ interface SignatureSectionProps {
onBeforeSign?: () => Promise<boolean> onBeforeSign?: () => Promise<boolean>
} }
function SignerAttributionBadge({ value }: { value: SignatureValue | '' }) {
const { t, i18n } = useTranslation()
const attribution = getSignatureAttribution(value)
if (!attribution) return null
const formattedDate = formatAppDateTime(attribution.signedAt, i18n.language)
return (
<div className="passkey-sign-badge valid signature-attribution-badge">
<span>{t('logs.sign_passkey_signed', { username: attribution.username })}</span>
<span className="passkey-sign-date">{formattedDate}</span>
</div>
)
}
function padValue(value: SignatureValue | ''): string { function padValue(value: SignatureValue | ''): string {
if (!value || isPasskeySignature(value)) return '' return getSignaturePayload(value)
return value
} }
function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode { function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode {
if (isPasskeySignature(value)) return 'passkey' if (isPasskeySignature(value)) return 'passkey'
if (value) return 'classic' if (getSignaturePayload(value)) return 'classic'
return passkeyAvailable ? 'passkey' : 'classic' return passkeyAvailable ? 'passkey' : 'classic'
} }
@@ -108,6 +123,7 @@ function RoleSignatureBlock({
} }
return ( return (
<div className="signature-role-block"> <div className="signature-role-block">
<SignerAttributionBadge value={value} />
<SignaturePad <SignaturePad
id={padId} id={padId}
label={roleLabel} label={roleLabel}
@@ -162,6 +178,7 @@ function RoleSignatureBlock({
{showClassicPanel && ( {showClassicPanel && (
<> <>
<SignerAttributionBadge value={value} />
<SignaturePad <SignaturePad
id={padId} id={padId}
label={roleLabel} label={roleLabel}
@@ -189,7 +206,7 @@ export default function SignatureSection({
disabled = false, disabled = false,
isOnline, isOnline,
canSignSkipper, canSignSkipper,
hasWriteCollaborators, canSignCrew,
signSkipper, signSkipper,
signCrew, signCrew,
skipperSignatureValid, skipperSignatureValid,
@@ -203,7 +220,7 @@ export default function SignatureSection({
const { t } = useTranslation() const { t } = useTranslation()
const showSkipperPasskey = canSignSkipper && isOnline const showSkipperPasskey = canSignSkipper && isOnline
const showCrewPasskey = hasWriteCollaborators && isOnline const showCrewPasskey = canSignCrew && isOnline
const hasSignature = !!(signSkipper || signCrew) const hasSignature = !!(signSkipper || signCrew)
return ( return (
@@ -228,7 +245,7 @@ export default function SignatureSection({
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined} passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
signatureValid={skipperSignatureValid} signatureValid={skipperSignatureValid}
showPasskey={showSkipperPasskey} showPasskey={showSkipperPasskey}
readOnly={readOnly} readOnly={readOnly || !canSignSkipper}
disabled={disabled} disabled={disabled}
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined} classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined} offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined}
@@ -245,7 +262,7 @@ export default function SignatureSection({
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined} passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
signatureValid={crewSignatureValid} signatureValid={crewSignatureValid}
showPasskey={showCrewPasskey} showPasskey={showCrewPasskey}
readOnly={readOnly} readOnly={readOnly || !canSignCrew}
disabled={disabled} disabled={disabled}
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined} classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
onChange={onSignCrewChange} onChange={onSignCrewChange}
+83 -1
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge } from 'lucide-react' import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge, Timer } from 'lucide-react'
import MultiTrackMap from './MultiTrackMap.tsx' import MultiTrackMap from './MultiTrackMap.tsx'
import { import {
formatLiters, formatLiters,
formatHours,
formatNm, formatNm,
loadAccountStats, loadAccountStats,
loadLogbookStats, loadLogbookStats,
@@ -12,6 +13,7 @@ import {
type TravelDayStats type TravelDayStats
} from '../services/statsAggregation.js' } from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js' import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
interface StatsDashboardProps { interface StatsDashboardProps {
logbookId: string logbookId: string
@@ -78,12 +80,26 @@ function TotalsGrid({ totals }: { totals: StatsTotals }) {
value={formatNm(totals.motorDistanceNm)} value={formatNm(totals.motorDistanceNm)}
unit={t('stats.unit_nm')} unit={t('stats.unit_nm')}
/> />
<KpiCard
icon={<Timer size={20} />}
label={t('stats.motor_hours_total')}
value={formatHours(totals.totalMotorHours)}
unit={t('stats.unit_h')}
/>
<KpiCard <KpiCard
icon={<Fuel size={20} />} icon={<Fuel size={20} />}
label={t('stats.fuel_total')} label={t('stats.fuel_total')}
value={formatLiters(totals.totalFuelL)} value={formatLiters(totals.totalFuelL)}
unit={t('stats.unit_l')} unit={t('stats.unit_l')}
/> />
{totals.fuelPerMotorHourL != null && (
<KpiCard
icon={<Timer size={20} />}
label={t('stats.fuel_per_motor_hour')}
value={formatFuelPerMotorHour(totals.fuelPerMotorHourL)}
unit={`${t('stats.unit_l')}/${t('stats.unit_h')}`}
/>
)}
<KpiCard <KpiCard
icon={<Droplets size={20} />} icon={<Droplets size={20} />}
label={t('stats.water_total')} label={t('stats.water_total')}
@@ -247,6 +263,36 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
/> />
</div> </div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
<p className="stats-section-sub">
{t('stats.avg_motor_hours')}: {formatHours(totals.avgMotorHoursPerDay)} {t('stats.unit_h')}
{totals.fuelPerMotorHourL != null && (
<>
{' · '}
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</>
)}
</p>
<DailyBarChart
days={travelDays}
valueFn={(d) => d.motorHours}
barClass="stats-bar--motor-hours"
formatValue={formatHours}
/>
{travelDays.some((d) => d.fuelPerMotorHourL != null) && (
<>
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
<DailyBarChart
days={travelDays}
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
barClass="stats-bar--fuel-per-hour"
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
/>
</>
)}
</div>
<div className="member-editor-card glass mt-6"> <div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3> <h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
<p className="stats-section-sub"> <p className="stats-section-sub">
@@ -256,6 +302,9 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
{totals.fuelPerNmL != null && ( {totals.fuelPerNmL != null && (
<> · {t('stats.fuel_per_nm')}: {totals.fuelPerNmL} {t('stats.unit_l')}/{t('stats.unit_nm')}</> <> · {t('stats.fuel_per_nm')}: {totals.fuelPerNmL} {t('stats.unit_l')}/{t('stats.unit_nm')}</>
)} )}
{totals.fuelPerMotorHourL != null && (
<> · {t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}</>
)}
</p> </p>
<ConsumptionChart days={travelDays} /> <ConsumptionChart days={travelDays} />
</div> </div>
@@ -367,6 +416,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<th>{t('stats.travel_days')}</th> <th>{t('stats.travel_days')}</th>
<th>{t('stats.total_distance')}</th> <th>{t('stats.total_distance')}</th>
<th>{t('stats.fuel_total')}</th> <th>{t('stats.fuel_total')}</th>
<th>{t('stats.motor_hours_total')}</th>
<th>{t('stats.water_total')}</th> <th>{t('stats.water_total')}</th>
</tr> </tr>
</thead> </thead>
@@ -377,6 +427,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<td>{lb.totals.travelDayCount}</td> <td>{lb.totals.travelDayCount}</td>
<td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</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.totalFuelL)} {t('stats.unit_l')}</td>
<td>{formatHours(lb.totals.totalMotorHours)} {t('stats.unit_h')}</td>
<td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td> <td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td>
</tr> </tr>
))} ))}
@@ -397,8 +448,39 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
/> />
</div> </div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
{accountStats.totals.fuelPerMotorHourL != null && (
<p className="stats-section-sub">
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</p>
)}
<DailyBarChart
days={allAccountDays}
valueFn={(d) => d.motorHours}
barClass="stats-bar--motor-hours"
formatValue={formatHours}
/>
{allAccountDays.some((d) => d.fuelPerMotorHourL != null) && (
<>
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
<DailyBarChart
days={allAccountDays}
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
barClass="stats-bar--fuel-per-hour"
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
/>
</>
)}
</div>
<div className="member-editor-card glass mt-6"> <div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3> <h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
{accountStats.totals.fuelPerMotorHourL != null && (
<p className="stats-section-sub">
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</p>
)}
<ConsumptionChart days={allAccountDays} /> <ConsumptionChart days={allAccountDays} />
</div> </div>
+782
View File
@@ -0,0 +1,782 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks'
import {
User,
ChevronLeft,
LogOut,
KeyRound,
Copy,
Check,
Plus,
Trash2,
BookOpen,
Anchor,
Gauge,
Sailboat,
Timer,
Share2,
Calendar,
Lock,
BarChart2,
Shield,
Smartphone,
RefreshCw,
Wifi,
WifiOff,
CircleCheck,
CircleAlert
} from 'lucide-react'
import AccountDangerZone from './AccountDangerZone.tsx'
import BetaBadge from './BetaBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
addPasskey,
fetchUserProfile,
forgetUsername,
getActiveMasterKey,
getKnownUsernames,
hasLocalPin,
removeLocalPin,
removePasskey,
renamePasskey,
rotateRecoveryPhrase,
setLocalPin,
type UserProfile
} from '../services/auth.js'
import {
formatHours,
formatNm,
loadAccountStats,
type AccountStatsSummary
} from '../services/statsAggregation.js'
import { db } from '../services/db.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
interface UserProfilePageProps {
onBack: () => void
onLogout: () => void
}
function formatAccountAge(createdAt: string, locale: string): string {
const created = new Date(createdAt)
if (Number.isNaN(created.getTime())) return createdAt
return created.toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
function KpiCard({
icon,
label,
value,
unit
}: {
icon: React.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 SecurityCheckItem({ ok, label }: { ok: boolean; label: string }) {
return (
<li className={`profile-security-item ${ok ? 'profile-security-item--ok' : 'profile-security-item--warn'}`}>
{ok ? <CircleCheck size={18} aria-hidden="true" /> : <CircleAlert size={18} aria-hidden="true" />}
<span>{label}</span>
</li>
)
}
export default function UserProfilePage({ onBack, onLogout }: UserProfilePageProps) {
const { t, i18n } = useTranslation()
const { showConfirm, showAlert } = useDialog()
const username = localStorage.getItem('active_username') || 'Skipper'
const [profile, setProfile] = useState<UserProfile | null>(null)
const [accountStats, setAccountStats] = useState<AccountStatsSummary | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [copiedUserId, setCopiedUserId] = useState(false)
const [passkeyBusy, setPasskeyBusy] = useState(false)
const [pinBusy, setPinBusy] = useState(false)
const [pinInput, setPinInput] = useState('')
const [pinConfirm, setPinConfirm] = useState('')
const [pinActive, setPinActive] = useState(() => hasLocalPin(username))
const [newPasskeyLabel, setNewPasskeyLabel] = useState('')
const [passkeyLabels, setPasskeyLabels] = useState<Record<string, string>>({})
const [online, setOnline] = useState(navigator.onLine)
const [isKnownDevice, setIsKnownDevice] = useState(() =>
getKnownUsernames().some((u) => u.toLowerCase() === username.toLowerCase())
)
const [recoveryBusy, setRecoveryBusy] = useState(false)
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null)
const [recoveryCopied, setRecoveryCopied] = useState(false)
const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0
const sharedLogbookCount = useLiveQuery(
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
[]
) ?? 0
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const profileData = await fetchUserProfile()
setProfile(profileData)
try {
const stats = await loadAccountStats(false)
setAccountStats(stats)
} catch (statsErr) {
console.error('Failed to load account stats for profile:', statsErr)
setAccountStats(null)
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('profile.load_error'))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => {
void loadData()
}, [loadData])
useEffect(() => {
trackPlausibleEvent(PlausibleEvents.PROFILE_OPENED)
}, [])
useEffect(() => {
const handleOnline = () => setOnline(true)
const handleOffline = () => setOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
useEffect(() => {
if (!profile) return
const labels: Record<string, string> = {}
for (const cred of profile.credentials) {
labels[cred.id] = cred.label ?? ''
}
setPasskeyLabels(labels)
}, [profile])
const statsTotals = accountStats?.totals
const logbookCount =
accountStats?.logbooks.length ?? profile?.serverMeta.ownedLogbookCount ?? 0
const accountAgeLabel = useMemo(() => {
if (!profile?.createdAt) return '—'
return formatAccountAge(profile.createdAt, i18n.language)
}, [profile?.createdAt, i18n.language])
const handleCopyUserId = async () => {
if (!profile?.userId) return
try {
await navigator.clipboard.writeText(profile.userId)
setCopiedUserId(true)
window.setTimeout(() => setCopiedUserId(false), 2000)
} catch {
showAlert(t('profile.copy_failed'))
}
}
const handleAddPasskey = async () => {
setPasskeyBusy(true)
setError(null)
try {
const hadLabel = Boolean(newPasskeyLabel.trim())
await addPasskey(newPasskeyLabel)
setNewPasskeyLabel('')
await loadData()
trackPlausibleEvent(PlausibleEvents.PASSKEY_ADDED, { labeled: hadLabel })
showAlert(t('profile.add_passkey_success'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('profile.add_passkey_failed'))
} finally {
setPasskeyBusy(false)
}
}
const handleRenamePasskey = async (credentialId: string) => {
setPasskeyBusy(true)
setError(null)
try {
await renamePasskey(credentialId, passkeyLabels[credentialId] ?? '')
await loadData()
trackPlausibleEvent(PlausibleEvents.PASSKEY_RENAMED)
showAlert(t('profile.passkey_rename_success'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('profile.passkey_rename_failed'))
} finally {
setPasskeyBusy(false)
}
}
const handleForgetDevice = async () => {
const confirmed = await showConfirm(
t('profile.device_forget_confirm_desc'),
t('profile.device_forget_confirm_title'),
t('profile.device_forget_confirm_yes'),
t('profile.device_forget_confirm_no')
)
if (!confirmed) return
forgetUsername(username)
setIsKnownDevice(false)
trackPlausibleEvent(PlausibleEvents.DEVICE_FORGOTTEN)
}
const handleRemovePasskey = async (credentialId: string) => {
if (profile && profile.credentials.length <= 1) {
trackPlausibleEvent(PlausibleEvents.LAST_PASSKEY_REMOVE_HINTED)
await showAlert(
t('profile.remove_passkey_last_desc'),
t('profile.remove_passkey_last_title')
)
return
}
const confirmed = await showConfirm(
t('profile.remove_passkey_confirm_desc'),
t('profile.remove_passkey_confirm_title'),
t('profile.remove_passkey_confirm_yes'),
t('profile.remove_passkey_confirm_no')
)
if (!confirmed) return
setPasskeyBusy(true)
setError(null)
try {
await removePasskey(credentialId)
await loadData()
trackPlausibleEvent(PlausibleEvents.PASSKEY_REMOVED)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('profile.remove_passkey_failed'))
} finally {
setPasskeyBusy(false)
}
}
const handleSavePin = async (e: React.FormEvent) => {
e.preventDefault()
if (pinInput.length < 4) {
setError(t('profile.pin_length_error'))
return
}
if (pinInput !== pinConfirm) {
setError(t('profile.pin_mismatch'))
return
}
const masterKey = getActiveMasterKey()
if (!masterKey) {
setError(t('profile.pin_no_session'))
return
}
const pinAction = pinActive ? 'change' : 'set'
setPinBusy(true)
setError(null)
try {
await setLocalPin(pinInput.trim(), username, masterKey)
setPinActive(true)
setPinInput('')
setPinConfirm('')
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_SET, { action: pinAction })
showAlert(t('profile.pin_saved'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('profile.pin_save_failed'))
} finally {
setPinBusy(false)
}
}
const handleRemovePin = async () => {
const confirmed = await showConfirm(
t('profile.remove_pin_confirm_desc'),
t('profile.remove_pin_confirm_title'),
t('profile.remove_pin_confirm_yes'),
t('profile.remove_pin_confirm_no')
)
if (!confirmed) return
removeLocalPin(username)
setPinActive(false)
setPinInput('')
setPinConfirm('')
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_REMOVED)
}
const handleRotateRecovery = async () => {
const confirmed = await showConfirm(
t('profile.recovery_rotate_confirm_desc'),
t('profile.recovery_rotate_confirm_title'),
t('profile.recovery_rotate_confirm_yes'),
t('profile.recovery_rotate_confirm_no')
)
if (!confirmed) return
if (!getActiveMasterKey()) {
setError(t('profile.recovery_rotate_no_session'))
return
}
setRecoveryBusy(true)
setError(null)
try {
const phrase = await rotateRecoveryPhrase()
setPendingRecoveryPhrase(phrase)
trackPlausibleEvent(PlausibleEvents.RECOVERY_ROTATED)
} catch (err: unknown) {
if (err instanceof Error && err.message === 'NO_ACTIVE_MASTER_KEY') {
setError(t('profile.recovery_rotate_no_session'))
} else {
setError(err instanceof Error ? err.message : t('profile.recovery_rotate_failed'))
}
} finally {
setRecoveryBusy(false)
}
}
const handleCopyRecoveryPhrase = async () => {
if (!pendingRecoveryPhrase) return
try {
await navigator.clipboard.writeText(pendingRecoveryPhrase)
setRecoveryCopied(true)
window.setTimeout(() => setRecoveryCopied(false), 2000)
} catch {
showAlert(t('profile.copy_failed'))
}
}
const handleConfirmRecoverySaved = () => {
setPendingRecoveryPhrase(null)
setRecoveryCopied(false)
}
return (
<div className="dashboard-container">
<header className="dashboard-header dashboard-header--profile">
<div className="header-brand profile-header-brand">
<button className="btn-back profile-back-btn" onClick={onBack} title={t('profile.back')}>
<ChevronLeft size={16} />
<span>{t('profile.back')}</span>
</button>
<div>
<div className="header-brand-title-row">
<h1>{t('profile.title')}</h1>
<BetaBadge />
</div>
<p className="subtitle">{t('profile.subtitle', { name: username })}</p>
</div>
</div>
<div className="header-actions">
<button className="btn-icon logout" onClick={onLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
</button>
</div>
</header>
<main className="profile-main">
{error && <div className="auth-error mb-4">{error}</div>}
{loading ? (
<div className="tab-placeholder">
<User className="header-logo spin" size={48} />
<p>{t('profile.loading')}</p>
</div>
) : pendingRecoveryPhrase ? (
<section className="form-card profile-recovery-card">
<div className="form-header">
<KeyRound size={24} className="form-icon" />
<h2>{t('auth.recovery_title')}</h2>
</div>
<p className="profile-recovery-warning">{t('profile.recovery_rotate_new_warning')}</p>
<div className="phrase-grid">
{pendingRecoveryPhrase.split(' ').map((word, idx) => (
<div key={idx} className="phrase-word">
<span className="word-num">{idx + 1}</span>
{word}
</div>
))}
</div>
<div className="form-actions profile-recovery-actions">
<button type="button" className="btn secondary" onClick={() => void handleCopyRecoveryPhrase()}>
{recoveryCopied ? t('auth.copied') : t('auth.copy_phrase')}
</button>
<button type="button" className="btn primary" onClick={handleConfirmRecoverySaved}>
{t('auth.confirm_recovery')}
</button>
</div>
</section>
) : profile ? (
<>
<section className="form-card">
<div className="form-header">
<User size={24} className="form-icon" />
<h2>{t('profile.identity_title')}</h2>
</div>
<dl className="profile-dl">
<div className="profile-dl-row">
<dt>{t('profile.username')}</dt>
<dd>{profile.username}</dd>
</div>
<div className="profile-dl-row">
<dt>{t('profile.user_id')}</dt>
<dd className="profile-user-id">
<code>{profile.userId}</code>
<button
type="button"
className="btn-icon profile-copy-btn"
onClick={() => void handleCopyUserId()}
title={t('profile.copy_user_id')}
>
{copiedUserId ? <Check size={16} /> : <Copy size={16} />}
</button>
</dd>
</div>
<div className="profile-dl-row">
<dt>{t('profile.account_since')}</dt>
<dd>{accountAgeLabel}</dd>
</div>
<div className="profile-dl-row">
<dt>{t('profile.prf_status')}</dt>
<dd>
{profile.hasPrfEncryption
? t('profile.prf_active')
: t('profile.prf_inactive')}
</dd>
</div>
</dl>
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Shield size={20} />
<h3>{t('profile.security_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.security_desc')}</p>
<ul className="profile-security-list">
<SecurityCheckItem
ok={profile.credentials.length > 0}
label={
profile.credentials.length > 0
? t('profile.security_passkeys_ok')
: t('profile.security_passkeys_missing')
}
/>
<SecurityCheckItem
ok={profile.hasPrfEncryption}
label={
profile.hasPrfEncryption
? t('profile.security_prf_ok')
: t('profile.security_prf_missing')
}
/>
<SecurityCheckItem
ok={pinActive}
label={pinActive ? t('profile.security_pin_ok') : t('profile.security_pin_missing')}
/>
<SecurityCheckItem ok label={t('profile.security_recovery_ok')} />
</ul>
<p className="profile-section-desc profile-recovery-hint">{t('profile.security_recovery_hint')}</p>
<div className="form-actions profile-recovery-actions">
<button
type="button"
className="btn secondary"
onClick={() => void handleRotateRecovery()}
disabled={recoveryBusy || passkeyBusy || pinBusy}
>
{recoveryBusy ? t('profile.processing') : t('profile.recovery_rotate_btn')}
</button>
</div>
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Smartphone size={20} />
<h3>{t('profile.device_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.device_desc')}</p>
<div className={`profile-device-status conn-status ${online ? (pendingSyncCount > 0 ? 'warning' : 'online') : 'offline'}`}>
{online ? (
pendingSyncCount > 0 ? (
<>
<RefreshCw size={16} className="spin" aria-hidden="true" />
<span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span>
</>
) : (
<>
<Wifi size={16} aria-hidden="true" />
<span>{t('profile.device_sync_ok')}</span>
</>
)
) : (
<>
<WifiOff size={16} aria-hidden="true" />
<span>{t('sync.status_offline')}</span>
</>
)}
</div>
<p className="profile-pin-status">
{isKnownDevice ? t('profile.device_remembered') : t('profile.device_not_remembered')}
</p>
{isKnownDevice && (
<div className="form-actions">
<button
type="button"
className="btn secondary"
onClick={() => void handleForgetDevice()}
>
{t('profile.device_forget_btn')}
</button>
</div>
)}
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Lock size={20} />
<h3>{t('profile.pin_title')}</h3>
</div>
<p className="profile-section-desc">{t('auth.setup_pin_warning')}</p>
<p className="profile-pin-status">
{t('profile.pin_status')}:{' '}
<strong>{pinActive ? t('profile.pin_active') : t('profile.pin_inactive')}</strong>
</p>
<form onSubmit={(e) => void handleSavePin(e)} className="profile-pin-form">
<div className="input-group">
<label htmlFor="profile-pin">{t('auth.pin_label')}</label>
<input
id="profile-pin"
type="password"
inputMode="numeric"
autoComplete="new-password"
className="input-text"
placeholder={t('auth.pin_placeholder')}
value={pinInput}
onChange={(e) => setPinInput(e.target.value)}
disabled={pinBusy}
/>
</div>
<div className="input-group">
<label htmlFor="profile-pin-confirm">{t('profile.pin_confirm_label')}</label>
<input
id="profile-pin-confirm"
type="password"
inputMode="numeric"
autoComplete="new-password"
className="input-text"
placeholder={t('profile.pin_confirm_placeholder')}
value={pinConfirm}
onChange={(e) => setPinConfirm(e.target.value)}
disabled={pinBusy}
/>
</div>
<div className="form-actions">
<button
type="submit"
className="btn primary"
disabled={pinBusy || pinInput.length < 4 || pinConfirm.length < 4}
>
{pinActive ? t('profile.pin_change_btn') : t('profile.pin_set_btn')}
</button>
{pinActive && (
<button
type="button"
className="btn secondary"
onClick={() => void handleRemovePin()}
disabled={pinBusy}
>
{t('profile.pin_remove_btn')}
</button>
)}
</div>
</form>
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<KeyRound size={20} />
<h3>{t('profile.passkeys_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.passkeys_desc')}</p>
{profile.credentials.length === 0 ? (
<p className="profile-empty">{t('profile.passkeys_empty')}</p>
) : (
<ul className="profile-passkey-list">
{profile.credentials.map((cred) => (
<li key={cred.id} className="profile-passkey-item">
<div className="profile-passkey-main">
<span className="profile-passkey-label">
{cred.label || t('profile.passkey_unnamed')}
</span>
<span className="profile-passkey-id">{cred.credentialIdPreview}</span>
{cred.transports.length > 0 && (
<span className="profile-passkey-transports">
{cred.transports.join(', ')}
</span>
)}
<div className="profile-passkey-rename">
<input
type="text"
className="input-text"
value={passkeyLabels[cred.id] ?? ''}
onChange={(e) =>
setPasskeyLabels((prev) => ({ ...prev, [cred.id]: e.target.value }))
}
placeholder={t('profile.passkey_label_placeholder')}
disabled={passkeyBusy}
maxLength={64}
/>
<button
type="button"
className="btn secondary"
onClick={() => void handleRenamePasskey(cred.id)}
disabled={passkeyBusy}
>
{t('profile.passkey_rename_btn')}
</button>
</div>
</div>
<button
type="button"
className="btn-icon danger"
onClick={() => void handleRemovePasskey(cred.id)}
disabled={passkeyBusy}
title={t('profile.remove_passkey_btn')}
>
<Trash2 size={16} />
</button>
</li>
))}
</ul>
)}
<div className="profile-add-passkey">
<div className="input-group">
<label htmlFor="profile-new-passkey-label">{t('profile.passkey_label')}</label>
<input
id="profile-new-passkey-label"
type="text"
className="input-text"
value={newPasskeyLabel}
onChange={(e) => setNewPasskeyLabel(e.target.value)}
placeholder={t('profile.passkey_label_placeholder')}
disabled={passkeyBusy}
maxLength={64}
/>
</div>
</div>
<div className="form-actions mt-4">
<button
type="button"
className="btn primary"
onClick={() => void handleAddPasskey()}
disabled={passkeyBusy}
>
<Plus size={16} />
{passkeyBusy ? t('profile.processing') : t('profile.add_passkey_btn')}
</button>
</div>
</section>
<section className="form-card">
<div className="form-header">
<BarChart2 size={24} className="form-icon" />
<div>
<h2>{t('profile.stats_title')}</h2>
<p className="stats-subtitle">{t('profile.stats_subtitle')}</p>
</div>
</div>
{(statsTotals || profile) && (
<div className="stats-kpi-grid">
<KpiCard
icon={<BookOpen size={20} />}
label={t('profile.stats_logbooks')}
value={String(logbookCount)}
/>
{statsTotals && (
<>
<KpiCard
icon={<Anchor size={20} />}
label={t('stats.travel_days')}
value={String(statsTotals.travelDayCount)}
/>
<KpiCard
icon={<Gauge size={20} />}
label={t('stats.total_distance')}
value={formatNm(statsTotals.totalDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Sailboat size={20} />}
label={t('stats.sail_distance')}
value={formatNm(statsTotals.sailDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Gauge size={20} />}
label={t('stats.motor_distance')}
value={formatNm(statsTotals.motorDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Timer size={20} />}
label={t('stats.motor_hours_total')}
value={formatHours(statsTotals.totalMotorHours)}
unit={t('stats.unit_h')}
/>
<KpiCard
icon={<Share2 size={20} />}
label={t('profile.stats_shared_logbooks')}
value={String(sharedLogbookCount)}
/>
</>
)}
<KpiCard
icon={<Calendar size={20} />}
label={t('profile.stats_account_since')}
value={accountAgeLabel}
/>
</div>
)}
</section>
<AccountDangerZone className="mt-6" />
</>
) : null}
</main>
</div>
)
}
@@ -0,0 +1,77 @@
import {
createContext,
useContext,
useCallback,
useEffect,
useRef,
useMemo,
type ReactNode
} from 'react'
import { useTranslation } from 'react-i18next'
import { useDialog } from '../components/ModalDialog.tsx'
interface UnsavedChangesContextValue {
setDirty: (source: string, dirty: boolean) => void
confirmLeave: () => Promise<boolean>
}
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null)
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const dirtySources = useRef(new Set<string>())
const setDirty = useCallback((source: string, dirty: boolean) => {
if (dirty) dirtySources.current.add(source)
else dirtySources.current.delete(source)
}, [])
const confirmLeave = useCallback(async (): Promise<boolean> => {
if (dirtySources.current.size === 0) return true
return showConfirm(
t('common.unsaved_changes_message'),
t('common.unsaved_changes_title'),
t('common.unsaved_changes_leave'),
t('common.unsaved_changes_stay')
)
}, [showConfirm, t])
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (dirtySources.current.size === 0) return
e.preventDefault()
e.returnValue = ''
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [])
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
return (
<UnsavedChangesContext.Provider value={value}>
{children}
</UnsavedChangesContext.Provider>
)
}
export function useUnsavedChangesContext(): UnsavedChangesContextValue {
const ctx = useContext(UnsavedChangesContext)
if (!ctx) {
throw new Error('useUnsavedChangesContext must be used within UnsavedChangesProvider')
}
return ctx
}
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
const { setDirty, confirmLeave } = useUnsavedChangesContext()
useEffect(() => {
setDirty(source, isDirty)
return () => setDirty(source, false)
}, [source, isDirty, setDirty])
return { confirmLeave }
}
+8 -1
View File
@@ -6,6 +6,8 @@ const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
const UPDATE_SUPPRESS_MS = 30_000 const UPDATE_SUPPRESS_MS = 30_000
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000 const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
const UPDATE_RELOAD_FALLBACK_MS = 2000 const UPDATE_RELOAD_FALLBACK_MS = 2000
/** Prevent Android PWA cold-start reload loops from onNeedReload. */
const PWA_INITIAL_RELOAD_KEY = 'pwa_sw_initial_reload_done'
function isUpdateSuppressed(): boolean { function isUpdateSuppressed(): boolean {
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0') const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
@@ -48,8 +50,13 @@ export function usePwaUpdate() {
needRefresh: [needRefresh, setNeedRefresh], needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker updateServiceWorker
} = useRegisterSW({ } = useRegisterSW({
immediate: true, immediate: !import.meta.env.DEV,
onNeedReload() { onNeedReload() {
// First SW takeover requires one reload; guard against repeated reloads on Android PWA resume.
if (sessionStorage.getItem(PWA_INITIAL_RELOAD_KEY)) {
return
}
sessionStorage.setItem(PWA_INITIAL_RELOAD_KEY, '1')
clearUpdateSuppression() clearUpdateSuppression()
setNeedRefresh(false) setNeedRefresh(false)
window.location.reload() window.location.reload()
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import deJson from './locales/de.json'
import enJson from './locales/en.json'
const resources = {
de: { translation: deJson.translation },
en: { translation: enJson.translation }
}
describe('course dial i18n keys', () => {
it.each([
'logs.event_course_section',
'logs.course_tab_mgk',
'logs.course_tab_rwk',
'logs.course_dial_hint',
'logs.course_step_fine',
'logs.wind_mode_cardinal'
])('resolves %s in de and en bundles', async (key) => {
const { default: i18n } = await import('i18next')
await i18n.init({ lng: 'de', resources, defaultNS: 'translation' })
expect(i18n.t(key)).not.toBe(key)
await i18n.changeLanguage('en')
expect(i18n.t(key)).not.toBe(key)
})
})
+18 -7
View File
@@ -1,25 +1,36 @@
import i18n from 'i18next' import i18n from 'i18next'
import { initReactI18next } from 'react-i18next' import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector' import LanguageDetector from 'i18next-browser-languagedetector'
import enTranslation from './locales/en.json' import enJson from './locales/en.json'
import deTranslation from './locales/de.json' import deJson from './locales/de.json'
import { initSeo } from '../utils/seo.js'
/** JSON files wrap strings in `translation` — register that namespace explicitly. */
const resources = {
en: { translation: enJson.translation },
de: { translation: deJson.translation }
}
i18n i18n
.use(LanguageDetector) .use(LanguageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
resources: { resources,
en: enTranslation, defaultNS: 'translation',
de: deTranslation
},
fallbackLng: 'en', fallbackLng: 'en',
supportedLngs: ['de', 'en'],
nonExplicitSupportedLngs: true,
load: 'languageOnly',
interpolation: { interpolation: {
escapeValue: false // React already escapes values (prevents XSS) escapeValue: false // React already escapes values (prevents XSS)
}, },
detection: { detection: {
order: ['localStorage', 'navigator'], order: ['querystring', 'localStorage', 'navigator'],
lookupQuerystring: 'lng',
caches: ['localStorage'] caches: ['localStorage']
} }
}) })
initSeo(i18n)
export default i18n export default i18n
+139 -2
View File
@@ -2,7 +2,15 @@
"translation": { "translation": {
"app": { "app": {
"name": "Kapteins Daagbok", "name": "Kapteins Daagbok",
"tagline": "Privates Yacht-Logbuch" "tagline": "Privates Yacht-Logbuch",
"beta": "Beta",
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
},
"common": {
"unsaved_changes_title": "Ungespeicherte Änderungen",
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
"unsaved_changes_leave": "Verlassen",
"unsaved_changes_stay": "Bleiben"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -143,6 +151,7 @@
"sign_passkey_signing": "Passkey wird angefordert…", "sign_passkey_signing": "Passkey wird angefordert…",
"sign_passkey_signed": "Freigegeben von {{username}}", "sign_passkey_signed": "Freigegeben von {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})", "sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Passkey-Freigabe entfernen", "sign_passkey_clear": "Passkey-Freigabe entfernen",
"sign_mode_passkey": "Passkey", "sign_mode_passkey": "Passkey",
"sign_mode_classic": "Klassisch", "sign_mode_classic": "Klassisch",
@@ -181,6 +190,23 @@
"event_time": "Uhrzeit", "event_time": "Uhrzeit",
"event_mgk": "MgK Kurs", "event_mgk": "MgK Kurs",
"event_rwk": "RwK Kurs", "event_rwk": "RwK Kurs",
"event_course_section": "Kurs",
"course_dial_hint": "Am Ring drehen oder Grad eingeben",
"course_dial_step_label": "Schrittweite",
"course_step_fine": "1°",
"course_step_medium": "5°",
"course_step_coarse": "10°",
"course_tab_mgk": "MgK",
"course_tab_rwk": "rwK",
"course_invalid": "Ungültiger Kurs (0360)",
"course_placeholder_degrees": "z. B. 180",
"course_placeholder_cardinal": "z. B. NW",
"compass_n": "N",
"compass_e": "O",
"compass_s": "S",
"compass_w": "W",
"wind_mode_cardinal": "Kardinal",
"wind_mode_degrees": "Als Grad",
"event_wind_direction": "Wind-Richtung", "event_wind_direction": "Wind-Richtung",
"event_wind_strength": "Windstärke", "event_wind_strength": "Windstärke",
"event_sea_state": "Seegang", "event_sea_state": "Seegang",
@@ -196,6 +222,8 @@
"event_heel": "Krängung (°)", "event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor", "event_sails": "Segelführung / Motor",
"motor_propulsion": "Maschinenfahrt", "motor_propulsion": "Maschinenfahrt",
"motor_hours": "Maschinenstunden (gesamt)",
"fuel_per_motor_hour": "Verbrauch pro Maschinenstunde",
"event_distance": "Distanz (sm)", "event_distance": "Distanz (sm)",
"export_csv": "CSV herunterladen", "export_csv": "CSV herunterladen",
"share_csv": "CSV teilen", "share_csv": "CSV teilen",
@@ -258,11 +286,106 @@
"role_crew": "Crew-Zugang", "role_crew": "Crew-Zugang",
"role_crew_hint": "Eingeladenes Logbuch — du kannst als Crew mitarbeiten und signieren", "role_crew_hint": "Eingeladenes Logbuch — du kannst als Crew mitarbeiten und signieren",
"role_read": "Nur Lesen", "role_read": "Nur Lesen",
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung" "role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
"open_profile": "Profil von {{name}} öffnen",
"edit_title": "Logbuch umbenennen",
"edit_placeholder": "Neuer Name des Logbuchs",
"edit_success": "Logbuch erfolgreich umbenannt",
"edit_btn": "Umbenennen"
},
"profile": {
"title": "Benutzerprofil",
"subtitle": "Konto, Passkeys und Statistiken für {{name}}",
"back": "Zurück zum Dashboard",
"loading": "Profil wird geladen…",
"load_error": "Profil konnte nicht geladen werden.",
"copy_failed": "Kopieren fehlgeschlagen.",
"processing": "Wird verarbeitet…",
"identity_title": "Konto-Identität",
"username": "Benutzername",
"user_id": "Benutzer-ID",
"copy_user_id": "Benutzer-ID kopieren",
"account_since": "Konto seit",
"prf_status": "Passkey-Schlüsselableitung (PRF)",
"prf_active": "Aktiv",
"prf_inactive": "Nicht eingerichtet",
"passkeys_title": "Passkeys",
"passkeys_desc": "Registriere auf jedem Gerät einen eigenen Passkey. So kannst du dich auch nach einem Plattformwechsel anmelden.",
"passkeys_empty": "Keine Passkeys gefunden.",
"add_passkey_btn": "Neuen Passkey hinzufügen",
"add_passkey_success": "Passkey erfolgreich hinzugefügt.",
"add_passkey_failed": "Passkey konnte nicht hinzugefügt werden.",
"remove_passkey_btn": "Passkey entfernen",
"remove_passkey_last_title": "Letzter Passkey",
"remove_passkey_last_desc": "Der einzige Passkey kann nicht entfernt werden, ohne den Zugang zu deinem Konto zu verlieren. Um das Konto vollständig zu löschen, nutze die Gefahrenzone am Ende dieser Seite.",
"remove_passkey_failed": "Passkey konnte nicht entfernt werden.",
"remove_passkey_confirm_title": "Passkey entfernen?",
"remove_passkey_confirm_desc": "Dieses Gerät kann sich danach nicht mehr mit diesem Passkey anmelden.",
"remove_passkey_confirm_yes": "Entfernen",
"remove_passkey_confirm_no": "Abbrechen",
"pin_title": "Lokaler PIN",
"pin_status": "Status",
"pin_active": "Aktiv auf diesem Gerät",
"pin_inactive": "Nicht eingerichtet",
"pin_confirm_label": "PIN bestätigen",
"pin_confirm_placeholder": "PIN erneut eingeben",
"pin_set_btn": "PIN einrichten",
"pin_change_btn": "PIN ändern",
"pin_remove_btn": "PIN entfernen",
"pin_saved": "PIN gespeichert.",
"pin_save_failed": "PIN konnte nicht gespeichert werden.",
"pin_mismatch": "Die PIN-Eingaben stimmen nicht überein.",
"pin_length_error": "Die PIN muss mindestens 4 Zeichen haben.",
"pin_no_session": "Sitzung abgelaufen — bitte erneut anmelden.",
"remove_pin_confirm_title": "PIN entfernen?",
"remove_pin_confirm_desc": "Du musst dich auf diesem Gerät wieder mit Passkey oder Wiederherstellungsschlüssel anmelden.",
"remove_pin_confirm_yes": "PIN entfernen",
"remove_pin_confirm_no": "Abbrechen",
"security_title": "Sicherheits-Checkliste",
"security_desc": "Überblick über die wichtigsten Schutzmechanismen deines Kontos.",
"security_passkeys_ok": "Mindestens ein Passkey registriert",
"security_passkeys_missing": "Kein Passkey registriert",
"security_prf_ok": "PRF-Schlüsselableitung aktiv",
"security_prf_missing": "PRF nicht eingerichtet",
"security_pin_ok": "Lokaler PIN auf diesem Gerät",
"security_pin_missing": "Kein lokaler PIN",
"security_recovery_ok": "Wiederherstellungsschlüssel eingerichtet",
"security_recovery_hint": "Die 12 Wörter wurden bei der Registrierung angezeigt. Bewahre sie offline und getrennt vom Gerät auf. Du kannst unten einen neuen Schlüssel erstellen — der alte wird dann ungültig.",
"recovery_rotate_btn": "Neuen Wiederherstellungsschlüssel erstellen",
"recovery_rotate_confirm_title": "Neuen Wiederherstellungsschlüssel erstellen?",
"recovery_rotate_confirm_desc": "Der bisherige 12-Wörter-Schlüssel wird sofort ungültig. Stelle sicher, dass du den neuen Schlüssel sicher aufbewahrst, bevor du fortfährst.",
"recovery_rotate_confirm_yes": "Neuen Schlüssel erstellen",
"recovery_rotate_confirm_no": "Abbrechen",
"recovery_rotate_new_warning": "WICHTIG: Schreib diese 12 Wörter auf und bewahre sie offline auf. Der bisherige Wiederherstellungsschlüssel ist ab sofort ungültig.",
"recovery_rotate_failed": "Wiederherstellungsschlüssel konnte nicht erstellt werden.",
"recovery_rotate_no_session": "Verschlüsselungssitzung abgelaufen — bitte abmelden und erneut anmelden, dann erneut versuchen.",
"device_title": "Dieses Gerät",
"device_desc": "Lokaler Cache, Sync-Status und Schnell-Login auf diesem Browser.",
"device_sync_pending": "{{count}} ausstehende Sync-Einträge",
"device_sync_ok": "Alle lokalen Änderungen synchronisiert",
"device_remembered": "Account für Schnell-Login auf diesem Gerät gespeichert",
"device_not_remembered": "Account nicht in der Schnell-Login-Liste",
"device_forget_btn": "Account auf diesem Gerät vergessen",
"device_forget_confirm_title": "Schnell-Login entfernen?",
"device_forget_confirm_desc": "Der Account verschwindet aus der Schnell-Login-Liste auf diesem Gerät. Deine Session und lokalen Logbücher bleiben erhalten.",
"device_forget_confirm_yes": "Entfernen",
"device_forget_confirm_no": "Abbrechen",
"passkey_label": "Name für neuen Passkey (optional)",
"passkey_label_placeholder": "z. B. MacBook, iPhone",
"passkey_rename_btn": "Name speichern",
"passkey_rename_success": "Passkey-Name gespeichert.",
"passkey_rename_failed": "Passkey-Name konnte nicht gespeichert werden.",
"passkey_unnamed": "Unbenannter Passkey",
"stats_title": "Statistiken",
"stats_subtitle": "Über alle deine Logbücher auf diesem Gerät",
"stats_logbooks": "Logbücher",
"stats_account_since": "Konto seit",
"stats_shared_logbooks": "Geteilte Logbücher"
}, },
"crew": { "crew": {
"title": "Skipper- & Crew-Profile", "title": "Skipper- & Crew-Profile",
"skipper_section": "Skipper-Profil", "skipper_section": "Skipper-Profil",
"skipper_read_only_hint": "Das Skipper-Profil kann nur vom Logbuch-Eigner bearbeitet werden.",
"crew_section": "Crew-Liste", "crew_section": "Crew-Liste",
"add_crew": "Crew-Mitglied hinzufügen", "add_crew": "Crew-Mitglied hinzufügen",
"edit_crew": "Crew-Mitglied bearbeiten", "edit_crew": "Crew-Mitglied bearbeiten",
@@ -320,6 +443,7 @@
"color_scheme_dark": "Dunkel", "color_scheme_dark": "Dunkel",
"share_title": "Logbuch teilen (Schreibgeschützt)", "share_title": "Logbuch teilen (Schreibgeschützt)",
"share_desc": "Aktiviere diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann deine Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).", "share_desc": "Aktiviere diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann deine Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
"share_privacy_warning": "Empfehlung: Teile diesen Link nur privat (z. B. per E-Mail oder Messenger), nicht in sozialen Medien.",
"share_enable": "Öffentlichen Link aktivieren", "share_enable": "Öffentlichen Link aktivieren",
"share_copied": "Link kopiert!", "share_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren", "share_copy_btn": "Link kopieren",
@@ -331,6 +455,7 @@
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen", "delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
"delete_account_confirm_no": "Abbrechen", "delete_account_confirm_no": "Abbrechen",
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.", "delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
"deleting_account": "Konto wird gelöscht…", "deleting_account": "Konto wird gelöscht…",
"tour_title": "App-Tour", "tour_title": "App-Tour",
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.", "tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
@@ -469,6 +594,9 @@
"travel_days": "Reisetage", "travel_days": "Reisetage",
"sail_distance": "Unter Segel", "sail_distance": "Unter Segel",
"motor_distance": "Maschinenfahrt", "motor_distance": "Maschinenfahrt",
"motor_hours_total": "Maschinenstunden gesamt",
"daily_motor_hours": "Maschinenstunden pro Reisetag",
"avg_motor_hours": "Ø Maschinenstunden pro Reisetag",
"unknown_propulsion": "Unbekannt", "unknown_propulsion": "Unbekannt",
"fuel_total": "Kraftstoff gesamt", "fuel_total": "Kraftstoff gesamt",
"water_total": "Wasser gesamt", "water_total": "Wasser gesamt",
@@ -482,9 +610,12 @@
"avg_fuel": "Ø Kraftstoff", "avg_fuel": "Ø Kraftstoff",
"avg_water": "Ø Wasser", "avg_water": "Ø Wasser",
"fuel_per_nm": "Kraftstoff pro sm", "fuel_per_nm": "Kraftstoff pro sm",
"fuel_per_motor_hour": "Kraftstoff pro Maschinenstunde",
"daily_fuel_per_motor_hour": "Kraftstoffverbrauch pro Maschinenstunde je Reisetag",
"fuel_legend": "Kraftstoff", "fuel_legend": "Kraftstoff",
"water_legend": "Wasser", "water_legend": "Wasser",
"unit_nm": "sm", "unit_nm": "sm",
"unit_h": "h",
"unit_l": "L", "unit_l": "L",
"day_label": "Tag {{day}}", "day_label": "Tag {{day}}",
"account_logbooks": "Logbücher im Überblick", "account_logbooks": "Logbücher im Überblick",
@@ -542,6 +673,12 @@
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!" "body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
} }
} }
},
"seo": {
"title": "Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)",
"description": "Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren auch offline als PWA.",
"keywords": "Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung",
"ogImageAlt": "Kapteins Daagbok Logo"
} }
} }
} }
+139 -2
View File
@@ -2,7 +2,15 @@
"translation": { "translation": {
"app": { "app": {
"name": "Kapteins Daagbok", "name": "Kapteins Daagbok",
"tagline": "Private Yacht Logbook" "tagline": "Private Yacht Logbook",
"beta": "Beta",
"beta_hint": "Beta release — features may still change"
},
"common": {
"unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
"unsaved_changes_leave": "Leave",
"unsaved_changes_stay": "Stay"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -143,6 +151,7 @@
"sign_passkey_signing": "Requesting Passkey…", "sign_passkey_signing": "Requesting Passkey…",
"sign_passkey_signed": "Signed by {{username}}", "sign_passkey_signed": "Signed by {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})", "sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Remove Passkey signature", "sign_passkey_clear": "Remove Passkey signature",
"sign_mode_passkey": "Passkey", "sign_mode_passkey": "Passkey",
"sign_mode_classic": "Classic", "sign_mode_classic": "Classic",
@@ -181,6 +190,23 @@
"event_time": "Time", "event_time": "Time",
"event_mgk": "MgK Course", "event_mgk": "MgK Course",
"event_rwk": "RwK Course", "event_rwk": "RwK Course",
"event_course_section": "Course",
"course_dial_hint": "Drag the ring or enter degrees",
"course_dial_step_label": "Step size",
"course_step_fine": "1°",
"course_step_medium": "5°",
"course_step_coarse": "10°",
"course_tab_mgk": "MgK",
"course_tab_rwk": "rwK",
"course_invalid": "Invalid course (0360)",
"course_placeholder_degrees": "e.g. 180",
"course_placeholder_cardinal": "e.g. NW",
"compass_n": "N",
"compass_e": "E",
"compass_s": "S",
"compass_w": "W",
"wind_mode_cardinal": "Cardinal",
"wind_mode_degrees": "As degrees",
"event_wind_direction": "Wind Dir", "event_wind_direction": "Wind Dir",
"event_wind_strength": "Wind Str", "event_wind_strength": "Wind Str",
"event_sea_state": "Sea State", "event_sea_state": "Sea State",
@@ -196,6 +222,8 @@
"event_heel": "Heel Angle (°)", "event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status", "event_sails": "Sails / Motor Status",
"motor_propulsion": "Engine Propulsion", "motor_propulsion": "Engine Propulsion",
"motor_hours": "Engine hours (total)",
"fuel_per_motor_hour": "Consumption per engine hour",
"event_distance": "Distance (nm)", "event_distance": "Distance (nm)",
"export_csv": "Download CSV", "export_csv": "Download CSV",
"share_csv": "Share CSV", "share_csv": "Share CSV",
@@ -258,11 +286,106 @@
"role_crew": "Crew access", "role_crew": "Crew access",
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew", "role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
"role_read": "Read only", "role_read": "Read only",
"role_read_hint": "Shared logbook — view only, no editing" "role_read_hint": "Shared logbook — view only, no editing",
"open_profile": "Open profile for {{name}}",
"edit_title": "Rename Logbook",
"edit_placeholder": "New name of the logbook",
"edit_success": "Logbook renamed successfully",
"edit_btn": "Rename"
},
"profile": {
"title": "User profile",
"subtitle": "Account, passkeys and statistics for {{name}}",
"back": "Back to dashboard",
"loading": "Loading profile…",
"load_error": "Could not load profile.",
"copy_failed": "Copy failed.",
"processing": "Processing…",
"identity_title": "Account identity",
"username": "Username",
"user_id": "User ID",
"copy_user_id": "Copy user ID",
"account_since": "Account since",
"prf_status": "Passkey key derivation (PRF)",
"prf_active": "Active",
"prf_inactive": "Not configured",
"passkeys_title": "Passkeys",
"passkeys_desc": "Register a passkey on each device you use. This helps when switching platforms or browsers.",
"passkeys_empty": "No passkeys found.",
"add_passkey_btn": "Add new passkey",
"add_passkey_success": "Passkey added successfully.",
"add_passkey_failed": "Could not add passkey.",
"remove_passkey_btn": "Remove passkey",
"remove_passkey_last_title": "Last passkey",
"remove_passkey_last_desc": "The only passkey cannot be removed without losing access to your account. To delete the account entirely, use the danger zone at the bottom of this page.",
"remove_passkey_failed": "Could not remove passkey.",
"remove_passkey_confirm_title": "Remove passkey?",
"remove_passkey_confirm_desc": "This device will no longer be able to sign in with this passkey.",
"remove_passkey_confirm_yes": "Remove",
"remove_passkey_confirm_no": "Cancel",
"pin_title": "Local PIN",
"pin_status": "Status",
"pin_active": "Active on this device",
"pin_inactive": "Not configured",
"pin_confirm_label": "Confirm PIN",
"pin_confirm_placeholder": "Re-enter PIN",
"pin_set_btn": "Set PIN",
"pin_change_btn": "Change PIN",
"pin_remove_btn": "Remove PIN",
"pin_saved": "PIN saved.",
"pin_save_failed": "Could not save PIN.",
"pin_mismatch": "PIN entries do not match.",
"pin_length_error": "PIN must be at least 4 characters.",
"pin_no_session": "Session expired — please sign in again.",
"remove_pin_confirm_title": "Remove PIN?",
"remove_pin_confirm_desc": "You will need to sign in on this device with passkey or recovery phrase again.",
"remove_pin_confirm_yes": "Remove PIN",
"remove_pin_confirm_no": "Cancel",
"security_title": "Security checklist",
"security_desc": "Overview of the most important protections for your account.",
"security_passkeys_ok": "At least one passkey registered",
"security_passkeys_missing": "No passkey registered",
"security_prf_ok": "PRF key derivation active",
"security_prf_missing": "PRF not configured",
"security_pin_ok": "Local PIN on this device",
"security_pin_missing": "No local PIN",
"security_recovery_ok": "Recovery phrase configured",
"security_recovery_hint": "The 12 words were shown at registration. Store them offline and separately from this device. You can create a new phrase below — the old one will then be invalidated.",
"recovery_rotate_btn": "Create new recovery phrase",
"recovery_rotate_confirm_title": "Create new recovery phrase?",
"recovery_rotate_confirm_desc": "Your previous 12-word phrase will be invalidated immediately. Make sure you can store the new phrase securely before continuing.",
"recovery_rotate_confirm_yes": "Create new phrase",
"recovery_rotate_confirm_no": "Cancel",
"recovery_rotate_new_warning": "IMPORTANT: Write down these 12 words and store them offline. Your previous recovery phrase is no longer valid.",
"recovery_rotate_failed": "Could not create a new recovery phrase.",
"recovery_rotate_no_session": "Encryption session expired — please sign out and sign in again, then retry.",
"device_title": "This device",
"device_desc": "Local cache, sync status, and quick login on this browser.",
"device_sync_pending": "{{count}} pending sync items",
"device_sync_ok": "All local changes synced",
"device_remembered": "Account saved for quick login on this device",
"device_not_remembered": "Account not in the quick-login list",
"device_forget_btn": "Forget account on this device",
"device_forget_confirm_title": "Remove quick login?",
"device_forget_confirm_desc": "The account will be removed from the quick-login list on this device. Your session and local logbooks stay on this device.",
"device_forget_confirm_yes": "Remove",
"device_forget_confirm_no": "Cancel",
"passkey_label": "Name for new passkey (optional)",
"passkey_label_placeholder": "e.g. MacBook, iPhone",
"passkey_rename_btn": "Save name",
"passkey_rename_success": "Passkey name saved.",
"passkey_rename_failed": "Could not save passkey name.",
"passkey_unnamed": "Unnamed passkey",
"stats_title": "Statistics",
"stats_subtitle": "Across all your logbooks on this device",
"stats_logbooks": "Logbooks",
"stats_account_since": "Account since",
"stats_shared_logbooks": "Shared logbooks"
}, },
"crew": { "crew": {
"title": "Skipper & Crew Profiles", "title": "Skipper & Crew Profiles",
"skipper_section": "Skipper Profile", "skipper_section": "Skipper Profile",
"skipper_read_only_hint": "The skipper profile can only be edited by the logbook owner.",
"crew_section": "Crew List", "crew_section": "Crew List",
"add_crew": "Add Crew Member", "add_crew": "Add Crew Member",
"edit_crew": "Edit Crew Member", "edit_crew": "Edit Crew Member",
@@ -320,6 +443,7 @@
"color_scheme_dark": "Dark", "color_scheme_dark": "Dark",
"share_title": "Share Logbook (Read-Only)", "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_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_privacy_warning": "Recommendation: Share this link only privately (e.g. via email or messenger), not on social media.",
"share_enable": "Enable Public Link", "share_enable": "Enable Public Link",
"share_copied": "Link copied!", "share_copied": "Link copied!",
"share_copy_btn": "Copy Link", "share_copy_btn": "Copy Link",
@@ -331,6 +455,7 @@
"delete_account_confirm_yes": "Yes, Delete Account and All Data", "delete_account_confirm_yes": "Yes, Delete Account and All Data",
"delete_account_confirm_no": "Cancel", "delete_account_confirm_no": "Cancel",
"delete_account_failed": "Failed to delete account. Please try again.", "delete_account_failed": "Failed to delete account. Please try again.",
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
"deleting_account": "Deleting account…", "deleting_account": "Deleting account…",
"tour_title": "App tour", "tour_title": "App tour",
"tour_desc": "Take a guided walkthrough of the main areas of the app again.", "tour_desc": "Take a guided walkthrough of the main areas of the app again.",
@@ -469,6 +594,9 @@
"travel_days": "Travel days", "travel_days": "Travel days",
"sail_distance": "Under sail", "sail_distance": "Under sail",
"motor_distance": "Engine", "motor_distance": "Engine",
"motor_hours_total": "Total engine hours",
"daily_motor_hours": "Engine hours per travel day",
"avg_motor_hours": "Avg. engine hours per travel day",
"unknown_propulsion": "Unknown", "unknown_propulsion": "Unknown",
"fuel_total": "Total fuel", "fuel_total": "Total fuel",
"water_total": "Total water", "water_total": "Total water",
@@ -482,9 +610,12 @@
"avg_fuel": "Avg. fuel", "avg_fuel": "Avg. fuel",
"avg_water": "Avg. water", "avg_water": "Avg. water",
"fuel_per_nm": "Fuel per nm", "fuel_per_nm": "Fuel per nm",
"fuel_per_motor_hour": "Fuel per engine hour",
"daily_fuel_per_motor_hour": "Fuel consumption per engine hour by travel day",
"fuel_legend": "Fuel", "fuel_legend": "Fuel",
"water_legend": "Water", "water_legend": "Water",
"unit_nm": "nm", "unit_nm": "nm",
"unit_h": "h",
"unit_l": "L", "unit_l": "L",
"day_label": "Day {{day}}", "day_label": "Day {{day}}",
"account_logbooks": "Logbooks overview", "account_logbooks": "Logbooks overview",
@@ -542,6 +673,12 @@
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!" "body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!"
} }
} }
},
"seo": {
"title": "Kapteins Daagbok Free Digital Yacht Logbook (Ad-Free)",
"description": "Free, ad-free digital yacht logbook with end-to-end encryption and Passkey sign-in. Document travel days, GPS tracks, crew and vessel data securely — offline-capable PWA.",
"keywords": "yacht logbook, ship logbook, sailing log, maritime logbook, passkey, E2E encryption, GPS track, free, ad-free, offline PWA",
"ogImageAlt": "Kapteins Daagbok logo"
} }
} }
} }
+48 -7
View File
@@ -3,14 +3,55 @@ import { createRoot } from 'react-dom/client'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
import './themes.css' import './themes.css'
import './index.css' import './index.css'
import App from './App.tsx'
import './i18n' import './i18n'
import App from './App.tsx'
import { applyAppearanceToDocument } from './services/appearance.ts' import { applyAppearanceToDocument } from './services/appearance.ts'
applyAppearanceToDocument() /** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise<void> {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
const regs = await navigator.serviceWorker.getRegistrations()
await Promise.all(regs.map((r) => r.unregister()))
if ('caches' in window) {
const keys = await caches.keys()
await Promise.all(keys.map((k) => caches.delete(k)))
}
}
createRoot(document.getElementById('root')!).render( function renderBootstrapError(message: string): void {
<StrictMode> const root = document.getElementById('root')
<App /> if (!root) return
</StrictMode>, root.innerHTML = `
) <div class="auth-screen">
<div class="auth-card glass" role="alert" style="max-width:420px">
<h2 style="margin-top:0">Kapteins Daagbok</h2>
<p style="color:var(--app-text-muted);line-height:1.5">${message}</p>
<button type="button" class="btn primary" style="width:100%;margin-top:16px" onclick="location.reload()">
Neu laden
</button>
</div>
</div>`
}
async function bootstrap(): Promise<void> {
applyAppearanceToDocument()
await clearDevServiceWorkerCaches()
const rootEl = document.getElementById('root')
if (!rootEl) {
throw new Error('Missing #root element')
}
createRoot(rootEl).render(
<StrictMode>
<App />
</StrictMode>,
)
}
void bootstrap().catch((err) => {
console.error('App bootstrap failed:', err)
renderBootstrapError(
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
)
})
+12 -1
View File
@@ -14,6 +14,8 @@ export const PlausibleEvents = {
ONBOARDING_TOUR_SKIPPED: 'Onboarding Tour Skipped', ONBOARDING_TOUR_SKIPPED: 'Onboarding Tour Skipped',
INVITE_GENERATED: 'Invite Generated', INVITE_GENERATED: 'Invite Generated',
INVITE_ACCEPTED: 'Invite Accepted', INVITE_ACCEPTED: 'Invite Accepted',
LOGBOOK_SHARED: 'Logbook Shared',
PUBLIC_LINK_OPENED: 'Public Link Opened',
PDF_EXPORTED: 'PDF Exported', PDF_EXPORTED: 'PDF Exported',
CSV_EXPORTED: 'CSV Exported', CSV_EXPORTED: 'CSV Exported',
CSV_SHARED: 'CSV Shared', CSV_SHARED: 'CSV Shared',
@@ -23,7 +25,16 @@ export const PlausibleEvents = {
DEMO_OPENED: 'Demo Opened', DEMO_OPENED: 'Demo Opened',
PUSH_ENABLED: 'Push Enabled', PUSH_ENABLED: 'Push Enabled',
PUSH_DISABLED: 'Push Disabled', PUSH_DISABLED: 'Push Disabled',
FOOTER_LINK_CLICKED: 'Footer Link Clicked' FOOTER_LINK_CLICKED: 'Footer Link Clicked',
PROFILE_OPENED: 'Profile Opened',
PASSKEY_ADDED: 'Passkey Added',
PASSKEY_REMOVED: 'Passkey Removed',
PASSKEY_RENAMED: 'Passkey Renamed',
LAST_PASSKEY_REMOVE_HINTED: 'Last Passkey Remove Hinted',
LOCAL_PIN_SET: 'Local PIN Set',
LOCAL_PIN_REMOVED: 'Local PIN Removed',
DEVICE_FORGOTTEN: 'Device Forgotten',
RECOVERY_ROTATED: 'Recovery Rotated'
} as const } as const
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents] export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
+150 -1
View File
@@ -33,13 +33,28 @@ export function setActiveMasterKey(key: ArrayBuffer | null) {
} }
export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> { export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), 8_000)
try { try {
return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`) return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`, {
signal: controller.signal
})
} catch { } catch {
return { authenticated: false } return { authenticated: false }
} finally {
window.clearTimeout(timeoutId)
} }
} }
/** Master key is memory-only; after process kill the HTTP session may outlive local crypto state. */
export function hasUnlockedLocalSession(): boolean {
return !!(
getActiveMasterKey() &&
localStorage.getItem('active_username') &&
localStorage.getItem('active_userid')
)
}
export async function reauthWithPasskey(): Promise<boolean> { export async function reauthWithPasskey(): Promise<boolean> {
const options = await apiJson<any>(`${API_BASE}/reauth-options`, { const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
method: 'POST' method: 'POST'
@@ -543,3 +558,137 @@ export async function deleteAccount(): Promise<boolean> {
} }
return false return false
} }
export interface UserProfileCredential {
id: string
label: string | null
credentialIdPreview: string
transports: string[]
}
export interface UserProfile {
userId: string
username: string
createdAt: string
hasPrfEncryption: boolean
credentials: UserProfileCredential[]
serverMeta: {
ownedLogbookCount: number
collaborationCount: number
}
}
export async function fetchUserProfile(): Promise<UserProfile> {
return apiJson<UserProfile>(`${API_BASE}/profile`)
}
async function enrollPrfFromMasterKey(masterKey: ArrayBuffer, prfFirst: ArrayBuffer): Promise<void> {
const prfKey = await deriveKeyFromPrf(prfFirst)
const encryptedPrf = await encryptBuffer(masterKey, prfKey)
await apiJson(`${API_BASE}/enroll-prf`, {
method: 'POST',
body: JSON.stringify({
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
encryptedMasterKeyPrfIv: encryptedPrf.iv,
encryptedMasterKeyPrfTag: encryptedPrf.tag
})
})
}
export async function addPasskey(label?: string): Promise<void> {
await reauthWithPasskey()
const options = await apiJson<any>(`${API_BASE}/add-credential-options`, {
method: 'POST'
})
if (!options.extensions) {
options.extensions = {}
}
options.extensions.prf = { eval: { first: PRF_SALT.buffer } }
let credentialResponse
const prfRequested = !!options.extensions?.prf
try {
credentialResponse = await startRegistration({ optionsJSON: options })
} catch (err: any) {
const isOptionError = err.name === 'NotSupportedError' ||
err.message?.toLowerCase().includes('options') ||
err.message?.toLowerCase().includes('process') ||
err.message?.toLowerCase().includes('unable to')
if (prfRequested && isOptionError) {
console.warn('Add passkey with PRF extension failed, retrying without PRF:', err)
if (options.extensions) {
delete options.extensions.prf
}
credentialResponse = await startRegistration({ optionsJSON: options })
} else {
throw err
}
}
await apiJson(`${API_BASE}/add-credential-verify`, {
method: 'POST',
body: JSON.stringify({
credentialResponse,
challenge: options.challenge,
...(label?.trim() ? { label: label.trim() } : {})
})
})
const masterKey = getActiveMasterKey()
const prfFirstBuffer = extractPrfFirst(credentialResponse.clientExtensionResults || {})
if (masterKey && prfFirstBuffer) {
try {
await enrollPrfFromMasterKey(masterKey, prfFirstBuffer)
} catch (err) {
console.error('Failed to enroll PRF after adding passkey:', err)
}
}
}
export async function removePasskey(credentialDbId: string): Promise<void> {
await reauthWithPasskey()
const res = await apiFetch(`${API_BASE}/credentials/${credentialDbId}`, {
method: 'DELETE'
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Failed to remove passkey')
}
}
export async function renamePasskey(credentialDbId: string, label: string): Promise<void> {
await reauthWithPasskey()
await apiJson(`${API_BASE}/credentials/${credentialDbId}`, {
method: 'PATCH',
body: JSON.stringify({ label })
})
}
export async function rotateRecoveryPhrase(): Promise<string> {
const masterKey = getActiveMasterKey()
if (!masterKey) {
throw new Error('NO_ACTIVE_MASTER_KEY')
}
await reauthWithPasskey()
const recoveryPhrase = generateRecoveryPhrase()
const recoveryKey = await deriveKeyFromPhrase(recoveryPhrase)
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
await apiJson(`${API_BASE}/rotate-recovery`, {
method: 'POST',
body: JSON.stringify({
encryptedMasterKeyRec: encryptedRecovery.ciphertext,
encryptedMasterKeyRecIv: encryptedRecovery.iv,
encryptedMasterKeyRecTag: encryptedRecovery.tag
})
})
return recoveryPhrase
}
+12 -5
View File
@@ -3,7 +3,9 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js' import { decryptJson } from './crypto.js'
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js' import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import i18n from '../i18n/index.js' import i18n from '../i18n/index.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
function escapeCsvValue(val: string | number | undefined | null): string { function escapeCsvValue(val: string | number | undefined | null): string {
if (val === null || val === undefined) return ''; if (val === null || val === undefined) return '';
@@ -79,7 +81,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const headers = [ const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
'Skipper Signature', 'Crew Signature', 'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course', 'Event Time', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)', 'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
@@ -93,8 +95,12 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const exportLabels = { const exportLabels = {
imagePlaceholder: i18n.t('logs.sign_export_image'), imagePlaceholder: i18n.t('logs.sign_export_image'),
passkeyLabel: (username: string, signedAt: string) => { passkeyLabel: (username: string, signedAt: string) => {
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB') const date = formatAppDateTime(signedAt, i18n.language)
return i18n.t('logs.sign_passkey_export', { username, date }) return i18n.t('logs.sign_passkey_export', { username, date })
},
attributionLabel: (username: string, signedAt: string) => {
const date = formatAppDateTime(signedAt, i18n.language)
return i18n.t('logs.sign_attribution_export', { username, date })
} }
}; };
@@ -108,6 +114,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const trackDist = entry.trackDistanceNm ?? ''; const trackDist = entry.trackDistanceNm ?? '';
const trackMax = entry.trackSpeedMaxKn ?? ''; const trackMax = entry.trackSpeedMaxKn ?? '';
const trackAvg = entry.trackSpeedAvgKn ?? ''; const trackAvg = entry.trackSpeedAvgKn ?? '';
const motorH = entry.motorHours ?? '';
const fwM = entry.freshwater?.morning ?? ''; const fwM = entry.freshwater?.morning ?? '';
const fwR = entry.freshwater?.refilled ?? ''; const fwR = entry.freshwater?.refilled ?? '';
const fwE = entry.freshwater?.evening ?? ''; const fwE = entry.freshwater?.evening ?? '';
@@ -123,7 +130,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, trackDist, trackMax, trackAvg, motorH,
'', '', '', '', '', '',
'', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
@@ -134,12 +141,12 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
].map(escapeCsvValue)); ].map(escapeCsvValue));
} else { } else {
// Sort events chronologically by time // Sort events chronologically by time
const sortedEvents = [...eventsList].sort((a, b) => (a.time || '').localeCompare(b.time || '')); const sortedEvents = sortLogEventsByTime(eventsList);
for (const ev of sortedEvents) { for (const ev of sortedEvents) {
rows.push([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '', ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '', ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '', ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
+8
View File
@@ -26,6 +26,7 @@ export interface DemoDaySpec {
filename: string filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number } freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number } fuel: { morning: number; refilled: number; evening: number; consumption: number }
motorHours?: number
events: Array<Record<string, string>> events: Array<Record<string, string>>
} }
@@ -100,6 +101,7 @@ export function buildDemoDays(): DemoDaySpec[] {
filename: 'laboe-damp.gpx', filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 }, freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 }, fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
motorHours: 1.5,
events: [ events: [
{ {
time: '09:00', time: '09:00',
@@ -247,6 +249,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
entryPayload.trackSpeedMaxKn = stats.speedMaxKn entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn entryPayload.trackSpeedAvgKn = stats.speedAvgKn
} }
if (day.motorHours != null && day.motorHours > 0) {
entryPayload.motorHours = day.motorHours
}
entries.push(entryPayload as PublicDemoFixture['entries'][number]) entries.push(entryPayload as PublicDemoFixture['entries'][number])
@@ -303,6 +308,9 @@ export function buildDemoEntryPayloads(): Array<{
entryPayload.trackSpeedMaxKn = stats.speedMaxKn entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn entryPayload.trackSpeedAvgKn = stats.speedAvgKn
} }
if (day.motorHours != null && day.motorHours > 0) {
entryPayload.motorHours = day.motorHours
}
return { return {
entryId, entryId,
+61
View File
@@ -322,3 +322,64 @@ export async function deleteLogbook(id: string): Promise<void> {
await deleteLocalLogbookCache(id) await deleteLocalLogbookCache(id)
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED) trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
} }
// Update the title of a logbook. Encrypts the title and updates locally + on server
export async function updateLogbookTitle(id: string, newTitle: string): Promise<void> {
const userId = localStorage.getItem('active_userid')
if (!userId) {
throw new Error('User not authenticated')
}
const masterKey = getActiveMasterKey()
if (!masterKey) {
throw new Error('Master key not found. User must log in.')
}
const logbookKey = await getLogbookKey(id) || masterKey
// E2E Encrypt the new title using the Logbook Key (or master key fallback)
const encrypted = await encryptJson(newTitle, logbookKey)
const encryptedTitleStr = JSON.stringify(encrypted)
const now = new Date().toISOString()
const payloadData = {
encryptedTitle: encryptedTitleStr
}
if (navigator.onLine) {
try {
const response = await apiFetch(`${API_BASE}/${id}`, {
method: 'PUT',
body: JSON.stringify(payloadData)
})
if (response.ok) {
// Update local IndexedDB cache as synced
await db.logbooks.update(id, {
encryptedTitle: encryptedTitleStr,
updatedAt: now,
isSynced: 1
})
return
}
} catch (error) {
console.warn('Failed to update logbook on server, saving locally instead:', error)
}
}
// If offline or request failed, store locally as unsynced and add to queue
await db.logbooks.update(id, {
encryptedTitle: encryptedTitleStr,
updatedAt: now,
isSynced: 0
})
await db.syncQueue.put({
action: 'update',
type: 'logbook',
payloadId: id,
logbookId: id,
data: JSON.stringify(payloadData),
updatedAt: now
})
}
+15 -6
View File
@@ -3,12 +3,13 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js' import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js' import { decryptJson } from './crypto.js'
import { isSignatureImage, isPasskeySignature } from '../utils/signatures.js' import { isSignatureImage, isPasskeySignature, isClassicSignature, getSignaturePayload } from '../utils/signatures.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import i18n from '../i18n/index.js' import i18n from '../i18n/index.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
function formatPasskeySignDate(signedAt: string): string { function formatPasskeySignDate(signedAt: string): string {
const locale = i18n.language === 'de' ? 'de-DE' : 'en-GB' return formatAppDateTime(signedAt, i18n.language)
return new Date(signedAt).toLocaleString(locale)
} }
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> { export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
@@ -132,7 +133,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
// Draw Data Rows // Draw Data Rows
const events = entry.events || []; const events = entry.events || [];
const maxRows = 16; const maxRows = 16;
const sortedEvents = [...events].sort((a: any, b: any) => (a.time || '').localeCompare(b.time || '')); const sortedEvents = sortLogEventsByTime(events);
doc.setFont('Helvetica', 'normal'); doc.setFont('Helvetica', 'normal');
@@ -255,8 +256,16 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt); const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
doc.text(`Passkey: ${entry.signCrew.username}`, sigX + 80.5, sigY + 9); doc.text(`Passkey: ${entry.signCrew.username}`, sigX + 80.5, sigY + 9);
doc.text(crewDate, sigX + 80.5, sigY + 13.5); doc.text(crewDate, sigX + 80.5, sigY + 13.5);
} else if (isSignatureImage(entry.signCrew)) { } else if (isClassicSignature(entry.signCrew)) {
doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14) doc.setFont('Helvetica', 'normal');
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
doc.text(entry.signCrew.username, sigX + 80.5, sigY + 9);
doc.text(crewDate, sigX + 80.5, sigY + 13.5);
if (isSignatureImage(entry.signCrew.payload)) {
doc.addImage(entry.signCrew.payload, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
}
} else if (isSignatureImage(getSignaturePayload(entry.signCrew))) {
doc.addImage(getSignaturePayload(entry.signCrew), 'PNG', sigX + 80.5, sigY + 6, 72, 14)
} else { } else {
doc.setFont('Helvetica', 'normal'); doc.setFont('Helvetica', 'normal');
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2); doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
+22 -2
View File
@@ -10,6 +10,7 @@ import {
parseEventDistanceNm, parseEventDistanceNm,
splitDistanceByPropulsion splitDistanceByPropulsion
} from '../utils/propulsionStats.js' } from '../utils/propulsionStats.js'
import { computeFuelPerMotorHour } from '../utils/fuelStats.js'
export type DistanceSource = 'gps' | 'events' | 'none' export type DistanceSource = 'gps' | 'events' | 'none'
@@ -27,6 +28,8 @@ export interface TravelDayStats {
sailDistanceNm: number sailDistanceNm: number
motorDistanceNm: number motorDistanceNm: number
unknownPropulsionNm: number unknownPropulsionNm: number
motorHours: number
fuelPerMotorHourL: number | null
hasGpsTrack: boolean hasGpsTrack: boolean
} }
@@ -59,12 +62,15 @@ export interface StatsTotals {
sailDistanceNm: number sailDistanceNm: number
motorDistanceNm: number motorDistanceNm: number
unknownPropulsionNm: number unknownPropulsionNm: number
totalMotorHours: number
totalFuelL: number totalFuelL: number
totalFreshwaterL: number totalFreshwaterL: number
avgDistancePerDayNm: number avgDistancePerDayNm: number
avgMotorHoursPerDay: number
avgFuelPerDayL: number avgFuelPerDayL: number
avgFreshwaterPerDayL: number avgFreshwaterPerDayL: number
fuelPerNmL: number | null fuelPerNmL: number | null
fuelPerMotorHourL: number | null
} }
const TRACK_COLORS = [ const TRACK_COLORS = [
@@ -102,6 +108,7 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0) const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0)
const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0) const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0)
const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 0) const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 0)
const totalMotorHours = days.reduce((sum, d) => sum + d.motorHours, 0)
const totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 0) const totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 0)
const totalFreshwaterL = days.reduce((sum, d) => sum + d.freshwaterConsumptionL, 0) const totalFreshwaterL = days.reduce((sum, d) => sum + d.freshwaterConsumptionL, 0)
@@ -112,10 +119,13 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
sailDistanceNm: Number(sailDistanceNm.toFixed(2)), sailDistanceNm: Number(sailDistanceNm.toFixed(2)),
motorDistanceNm: Number(motorDistanceNm.toFixed(2)), motorDistanceNm: Number(motorDistanceNm.toFixed(2)),
unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)), unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)),
totalMotorHours: Number(totalMotorHours.toFixed(1)),
totalFuelL: Number(totalFuelL.toFixed(1)), totalFuelL: Number(totalFuelL.toFixed(1)),
totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)), totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)),
avgDistancePerDayNm: avgDistancePerDayNm:
travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0, travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0,
avgMotorHoursPerDay:
travelDayCount > 0 ? Number((totalMotorHours / travelDayCount).toFixed(1)) : 0,
avgFuelPerDayL: avgFuelPerDayL:
travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0, travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0,
avgFreshwaterPerDayL: avgFreshwaterPerDayL:
@@ -123,7 +133,8 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
fuelPerNmL: fuelPerNmL:
totalDistanceNm > 0 && totalFuelL > 0 totalDistanceNm > 0 && totalFuelL > 0
? Number((totalFuelL / totalDistanceNm).toFixed(2)) ? Number((totalFuelL / totalDistanceNm).toFixed(2))
: null : null,
fuelPerMotorHourL: computeFuelPerMotorHour(totalFuelL, totalMotorHours)
} }
} }
@@ -180,6 +191,9 @@ async function loadTravelDaysForLogbook(
hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId)) hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId))
} }
const fuelConsumptionL = Number(payload.fuel?.consumption) || 0
const motorHours = Number(payload.motorHours) || 0
days.push({ days.push({
entryId: entry.payloadId, entryId: entry.payloadId,
logbookId, logbookId,
@@ -189,11 +203,13 @@ async function loadTravelDaysForLogbook(
destination: payload.destination || '', destination: payload.destination || '',
distanceNm, distanceNm,
distanceSource, distanceSource,
fuelConsumptionL: Number(payload.fuel?.consumption) || 0, fuelConsumptionL,
freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0, freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0,
sailDistanceNm: propulsion.sailDistanceNm, sailDistanceNm: propulsion.sailDistanceNm,
motorDistanceNm: propulsion.motorDistanceNm, motorDistanceNm: propulsion.motorDistanceNm,
unknownPropulsionNm: propulsion.unknownPropulsionNm, unknownPropulsionNm: propulsion.unknownPropulsionNm,
motorHours,
fuelPerMotorHourL: computeFuelPerMotorHour(fuelConsumptionL, motorHours),
hasGpsTrack hasGpsTrack
}) })
} }
@@ -249,3 +265,7 @@ export function formatNm(value: number): string {
export function formatLiters(value: number): string { export function formatLiters(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1) return Number.isInteger(value) ? String(value) : value.toFixed(1)
} }
export function formatHours(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
+13 -2
View File
@@ -11,5 +11,16 @@ export interface PasskeySignature {
clientVerified: boolean clientVerified: boolean
} }
/** Legacy: PNG data URL oder getippter Name */ /** Klassische Unterschrift mit Benutzer-Zuordnung (Crew) */
export type SignatureValue = string | PasskeySignature export interface ClassicSignature {
kind: 'classic'
version: 1
role: 'skipper' | 'crew'
userId: string
username: string
signedAt: string
payload: string
}
/** Legacy: PNG data URL oder getippter Name; oder strukturierte Signaturen */
export type SignatureValue = string | PasskeySignature | ClassicSignature
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import {
cardinalToDegrees,
degreesToCardinal,
formatCourseAngle,
isCardinalDirection,
normalizeCourseAngleString,
normalizeWindDirectionString,
parseCourseAngle,
pointerAngleToDegrees,
snapDegrees
} from './courseAngle.js'
describe('parseCourseAngle', () => {
it('parses padded and plain degrees', () => {
expect(parseCourseAngle('042')).toBe(42)
expect(parseCourseAngle('185°')).toBe(185)
expect(parseCourseAngle('360')).toBe(0)
})
it('rejects invalid values', () => {
expect(parseCourseAngle('999')).toBeNull()
expect(parseCourseAngle('abc')).toBeNull()
})
it('parses cardinal labels', () => {
expect(parseCourseAngle('NW')).toBe(315)
})
})
describe('snapDegrees', () => {
it('snaps to step', () => {
expect(snapDegrees(47, 5)).toBe(45)
expect(snapDegrees(358, 5)).toBe(0)
})
})
describe('cardinal helpers', () => {
it('roundtrips cardinal through degrees', () => {
expect(degreesToCardinal(225)).toBe('SW')
expect(cardinalToDegrees('SW')).toBe(225)
expect(isCardinalDirection('nne')).toBe(true)
})
})
describe('pointerAngleToDegrees', () => {
it('returns 0 for north', () => {
expect(pointerAngleToDegrees(100, 50, 100, 100)).toBe(0)
})
it('returns 90 for east', () => {
expect(Math.round(pointerAngleToDegrees(150, 100, 100, 100))).toBe(90)
})
})
describe('normalizeCourseAngleString', () => {
it('keeps empty when allowed', () => {
expect(normalizeCourseAngleString('', { allowEmpty: true })).toBe('')
})
it('normalizes numeric course', () => {
expect(normalizeCourseAngleString('042')).toBe('42')
expect(formatCourseAngle(42, true)).toBe('042')
})
})
describe('normalizeWindDirectionString', () => {
it('preserves cardinal wind', () => {
expect(normalizeWindDirectionString('nw')).toBe('NW')
})
it('normalizes degree wind', () => {
expect(normalizeWindDirectionString('090')).toBe('90')
})
})
+160
View File
@@ -0,0 +1,160 @@
export const CARDINAL_DIRECTIONS = [
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'
] as const
export type CardinalDirection = (typeof CARDINAL_DIRECTIONS)[number]
export type CourseStep = 1 | 5 | 10
const CARDINAL_SET = new Set<string>(CARDINAL_DIRECTIONS)
export function isCardinalDirection(value: string): boolean {
return CARDINAL_SET.has(value.trim().toUpperCase())
}
export function cardinalToDegrees(label: string): number | null {
const upper = label.trim().toUpperCase()
const index = CARDINAL_DIRECTIONS.indexOf(upper as CardinalDirection)
if (index < 0) return null
return (index * 22.5) % 360
}
export function degreesToCardinal(degrees: number): CardinalDirection {
const normalized = ((degrees % 360) + 360) % 360
const index = Math.round(normalized / 22.5) % 16
return CARDINAL_DIRECTIONS[index]
}
export function snapDegrees(degrees: number, step: CourseStep): number {
const normalized = ((degrees % 360) + 360) % 360
const snapped = Math.round(normalized / step) * step
return snapped >= 360 ? 0 : snapped
}
/** 0° = north, clockwise (maritime compass). */
export function pointerAngleToDegrees(
clientX: number,
clientY: number,
centerX: number,
centerY: number
): number {
const dx = clientX - centerX
const dy = centerY - clientY
const radians = Math.atan2(dx, dy)
let degrees = (radians * 180) / Math.PI
if (degrees < 0) degrees += 360
return degrees
}
export function parseCourseAngle(value: string): number | null {
const trimmed = value.trim().replace(/°/g, '')
if (!trimmed) return null
const cardinalDeg = cardinalToDegrees(trimmed)
if (cardinalDeg !== null) return Math.round(cardinalDeg)
if (!/^\d{1,3}$/.test(trimmed)) return null
const degrees = parseInt(trimmed, 10)
if (Number.isNaN(degrees)) return null
if (degrees === 360) return 0
if (degrees < 0 || degrees > 360) return null
return degrees
}
export function formatCourseAngle(degrees: number, pad = false): string {
const normalized = ((Math.round(degrees) % 360) + 360) % 360
const text = String(normalized)
return pad ? text.padStart(3, '0') : text
}
export function normalizeCourseAngleString(
value: string,
options?: { allowEmpty?: boolean }
): string {
const trimmed = value.trim()
if (!trimmed) return options?.allowEmpty ? '' : ''
if (isCardinalDirection(trimmed)) {
return trimmed.toUpperCase()
}
const parsed = parseCourseAngle(trimmed)
if (parsed === null) return trimmed
return formatCourseAngle(parsed)
}
export function normalizeWindDirectionString(value: string): string {
const trimmed = value.trim()
if (!trimmed) return ''
if (isCardinalDirection(trimmed)) {
return trimmed.toUpperCase()
}
const parsed = parseCourseAngle(trimmed)
if (parsed === null) return trimmed
return formatCourseAngle(parsed)
}
export function valueToDialDegrees(value: string, allowCardinal = false): number {
const parsed = parseCourseAngle(value)
if (parsed !== null) return parsed
if (allowCardinal && isCardinalDirection(value)) {
return cardinalToDegrees(value) ?? 0
}
return 0
}
export type CourseOutputMode = 'degrees' | 'cardinal'
export function resolveCourseOutputMode(
value: string,
displayMode: 'degrees' | 'cardinal' | 'auto',
allowCardinal: boolean
): CourseOutputMode {
if (!allowCardinal || displayMode === 'degrees') return 'degrees'
if (displayMode === 'cardinal') return 'cardinal'
return isCardinalDirection(value) ? 'cardinal' : 'degrees'
}
export function dialDegreesToStorageValue(
degrees: number,
mode: CourseOutputMode,
step: CourseStep
): string {
const snapped = snapDegrees(degrees, step)
if (mode === 'cardinal') return degreesToCardinal(snapped)
return formatCourseAngle(snapped)
}
export function formatCourseDisplay(
value: string,
allowCardinal: boolean
): string {
if (!value.trim()) return '—'
if (allowCardinal && isCardinalDirection(value)) return value.toUpperCase()
const parsed = parseCourseAngle(value)
if (parsed === null) return value
return `${formatCourseAngle(parsed, true)}°`
}
const STEP_STORAGE_KEY = 'kaptein-course-dial-step'
export function loadCourseDialStep(): CourseStep {
try {
const raw = sessionStorage.getItem(STEP_STORAGE_KEY)
if (raw === '5') return 5
if (raw === '10') return 10
} catch {
/* ignore */
}
return 1
}
export function saveCourseDialStep(step: CourseStep): void {
try {
sessionStorage.setItem(STEP_STORAGE_KEY, String(step))
} catch {
/* ignore */
}
}
+37
View File
@@ -0,0 +1,37 @@
/** BCP 47 locales that use 24-hour clock for Intl formatting. */
export function resolveIntlLocale(language?: string): string {
const lng = (language ?? 'en').toLowerCase()
return lng.startsWith('de') ? 'de-DE' : 'en-GB'
}
const APP_DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}
const APP_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
hour12: false
}
function toDate(value: Date | string | number): Date | null {
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
export function formatAppDateTime(value: Date | string | number, language?: string): string {
const date = toDate(value)
if (!date) return String(value)
return date.toLocaleString(resolveIntlLocale(language), APP_DATE_TIME_OPTIONS)
}
export function formatAppTime(value: Date | string | number, language?: string): string {
const date = toDate(value)
if (!date) return String(value)
return date.toLocaleTimeString(resolveIntlLocale(language), APP_TIME_OPTIONS)
}
+13
View File
@@ -0,0 +1,13 @@
/** Liters per motor hour from daily fuel consumption and motor hours. */
export function computeFuelPerMotorHour(
fuelConsumptionL: number,
motorHours: number
): number | null {
if (motorHours <= 0) return null
return Number((fuelConsumptionL / motorHours).toFixed(2))
}
export function formatFuelPerMotorHour(value: number | null | undefined): string {
if (value == null) return '—'
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
+62
View File
@@ -0,0 +1,62 @@
import type { i18n as I18nInstance } from 'i18next'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resolveIntlLocale } from './dateTimeFormat.js'
import { initSeo, normalizeSeoLang, updatePageSeo } from './seo.js'
const HTML_LANG = /^de|en$/
function createMockI18n(language: string): I18nInstance {
return {
isInitialized: true,
language,
t: (key: string) => key,
on: vi.fn()
} as unknown as I18nInstance
}
describe('normalizeSeoLang', () => {
it.each([
['de', 'de'],
['de-DE', 'de'],
['en', 'en'],
['en-US', 'en'],
['en-GB', 'en']
] as const)('maps %s to short code %s', (input, expected) => {
expect(normalizeSeoLang(input)).toBe(expected)
})
})
describe('updatePageSeo html lang', () => {
beforeEach(() => {
document.documentElement.lang = 'de'
window.history.replaceState({}, '', '/')
})
it.each([
['de', 'de'],
['en', 'en'],
['en-GB', 'en']
] as const)('sets html lang to %s when i18n language is %s', (i18nLanguage, expectedLang) => {
initSeo(createMockI18n(i18nLanguage))
updatePageSeo()
expect(document.documentElement.lang).toBe(expectedLang)
expect(document.documentElement.lang).toMatch(HTML_LANG)
})
})
describe('resolveIntlLocale', () => {
it('uses full BCP 47 tags for Intl formatting only', () => {
expect(resolveIntlLocale('de')).toBe('de-DE')
expect(resolveIntlLocale('en')).toBe('en-GB')
})
it('does not reuse Intl locale tags for html lang', () => {
const intlLocale = resolveIntlLocale('en')
const htmlLang = normalizeSeoLang('en')
expect(intlLocale).toBe('en-GB')
expect(htmlLang).toBe('en')
expect(htmlLang).not.toBe(intlLocale)
})
})
+104 -1
View File
@@ -1,3 +1,8 @@
import {
normalizeCourseAngleString,
normalizeWindDirectionString
} from './courseAngle.js'
export interface LogEventPayload { export interface LogEventPayload {
time: string time: string
mgk: string mgk: string
@@ -17,6 +22,100 @@ export interface LogEventPayload {
remarks: string remarks: string
} }
/** Local time as HH:MM (24-hour). */
export function currentLocalTimeHHMM(date: Date = new Date()): string {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
/** Parse 24h or 12h (AM/PM) time strings to HH:MM. */
export function parseTimeToHHMM(value: string): string | null {
const trimmed = value.trim()
if (!trimmed) return null
const amPm = trimmed.match(/^(\d{1,2}):(\d{2})(?::\d{2})?\s*(AM|PM)$/i)
if (amPm) {
let hours = parseInt(amPm[1], 10)
const minutes = parseInt(amPm[2], 10)
const isPm = amPm[3].toUpperCase() === 'PM'
if (hours < 1 || hours > 12 || minutes < 0 || minutes > 59) return null
if (hours === 12) hours = isPm ? 12 : 0
else if (isPm) hours += 12
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
}
const h24 = trimmed.match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/)
if (h24) {
const hours = parseInt(h24[1], 10)
const minutes = parseInt(h24[2], 10)
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
}
}
return null
}
export function isValidTimeHHMM(value: string): boolean {
return parseTimeToHHMM(value) !== null
}
export function splitTimeHHMM(value: string): { hours: string; minutes: string } {
const parsed = parseTimeToHHMM(value) ?? currentLocalTimeHHMM()
return { hours: parsed.slice(0, 2), minutes: parsed.slice(3, 5) }
}
export function joinTimeHHMM(hours: string, minutes: string): string {
const h = Math.min(23, Math.max(0, parseInt(hours, 10) || 0))
const m = Math.min(59, Math.max(0, parseInt(minutes, 10) || 0))
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'gpsLat', 'gpsLng', 'remarks'
]
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<string, unknown>): LogEventPayload {
const e = event as Record<string, unknown>
const timeRaw = String(e.time ?? '').trim()
const normalized: LogEventPayload = {
time: parseTimeToHHMM(timeRaw) ?? (timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw),
mgk: normalizeCourseAngleString(String(e.mgk ?? ''), { allowEmpty: true }),
rwk: normalizeCourseAngleString(String(e.rwk ?? ''), { allowEmpty: true }),
windPressure: '',
windDirection: normalizeWindDirectionString(String(e.windDirection ?? '')),
windStrength: '',
seaState: '',
weatherIcon: '',
current: '',
heel: '',
sailsOrMotor: '',
logReading: '',
distance: '',
gpsLat: '',
gpsLng: '',
remarks: ''
}
for (const key of LOG_EVENT_FIELDS) {
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection') continue
normalized[key] = String(e[key] ?? '').trim()
}
return normalized
}
export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean {
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
}
/** Chronological order: earliest time first (HH:MM). */
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
}
export interface LogEntryPayloadInput { export interface LogEntryPayloadInput {
date: string date: string
dayOfTravel: string dayOfTravel: string
@@ -27,6 +126,7 @@ export interface LogEntryPayloadInput {
trackDistanceNm?: number trackDistanceNm?: number
trackSpeedMaxKn?: number trackSpeedMaxKn?: number
trackSpeedAvgKn?: number trackSpeedAvgKn?: number
motorHours?: number
events: LogEventPayload[] events: LogEventPayload[]
} }
@@ -38,12 +138,15 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
destination: input.destination.trim(), destination: input.destination.trim(),
freshwater: { ...input.freshwater }, freshwater: { ...input.freshwater },
fuel: { ...input.fuel }, fuel: { ...input.fuel },
events: input.events.map((e) => ({ ...e })) events: sortLogEventsByTime(input.events.map((e) => normalizeLogEvent(e)))
} }
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn
if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn
if (input.motorHours !== undefined && input.motorHours > 0) {
payload.motorHours = Number(input.motorHours.toFixed(2))
}
return payload return payload
} }
+73
View File
@@ -0,0 +1,73 @@
import type { i18n as I18nInstance } from 'i18next'
const SITE_ORIGIN = 'https://kapteins-daagbok.eu'
export type SeoLang = 'de' | 'en'
let i18nRef: I18nInstance | null = null
export function normalizeSeoLang(lng: string): SeoLang {
return lng.startsWith('de') ? 'de' : 'en'
}
function setMeta(attr: 'name' | 'property', key: string, content: string) {
let el = document.querySelector(`meta[${attr}="${key}"]`)
if (!el) {
el = document.createElement('meta')
el.setAttribute(attr, key)
document.head.appendChild(el)
}
el.setAttribute('content', content)
}
function syncLanguageUrl(lang: SeoLang) {
const url = new URL(window.location.href)
const currentLng = url.searchParams.get('lng')
if (currentLng && normalizeSeoLang(currentLng) === lang) return
url.searchParams.set('lng', lang)
const next = `${url.pathname}${url.search}${url.hash}`
window.history.replaceState({}, '', next)
}
export function updatePageSeo(lng?: string) {
if (!i18nRef?.isInitialized) return
const lang = normalizeSeoLang(lng ?? i18nRef.language)
document.documentElement.lang = lang
const title = i18nRef.t('seo.title')
document.title = title
const description = i18nRef.t('seo.description')
const keywords = i18nRef.t('seo.keywords')
const imageAlt = i18nRef.t('seo.ogImageAlt')
setMeta('name', 'description', description)
setMeta('name', 'keywords', keywords)
setMeta('property', 'og:title', title)
setMeta('property', 'og:description', description)
setMeta('property', 'og:locale', lang === 'de' ? 'de_DE' : 'en_US')
setMeta('property', 'og:locale:alternate', lang === 'de' ? 'en_US' : 'de_DE')
setMeta('name', 'twitter:title', title)
setMeta('name', 'twitter:description', description)
setMeta('property', 'og:image:alt', imageAlt)
setMeta('name', 'twitter:image:alt', imageAlt)
syncLanguageUrl(lang)
}
export function initSeo(i18n: I18nInstance) {
i18nRef = i18n
i18n.on('initialized', () => updatePageSeo())
i18n.on('languageChanged', (lng) => updatePageSeo(lng))
if (i18n.isInitialized) {
updatePageSeo()
}
}
export function hreflangUrl(lang: SeoLang): string {
return `${SITE_ORIGIN}/?lng=${lang}`
}
export const seoSiteOrigin = SITE_ORIGIN
+57 -4
View File
@@ -1,8 +1,13 @@
import { hashEntryForSigning } from './entryCanonicalHash.js' import { hashEntryForSigning } from './entryCanonicalHash.js'
import type { PasskeySignature, SignatureValue } from '../types/signatures.js' import type { ClassicSignature, PasskeySignature, SignatureValue } from '../types/signatures.js'
export type SkipperSignStatus = 'none' | 'valid' | 'invalid' export type SkipperSignStatus = 'none' | 'valid' | 'invalid'
export interface SignatureAttribution {
username: string
signedAt: string
}
export function isSignatureImage(value: string | undefined | null): boolean { export function isSignatureImage(value: string | undefined | null): boolean {
return typeof value === 'string' && value.startsWith('data:image/') return typeof value === 'string' && value.startsWith('data:image/')
} }
@@ -16,9 +21,52 @@ export function isPasskeySignature(value: unknown): value is PasskeySignature {
) )
} }
export function isClassicSignature(value: unknown): value is ClassicSignature {
return (
typeof value === 'object' &&
value !== null &&
(value as ClassicSignature).kind === 'classic' &&
(value as ClassicSignature).version === 1
)
}
export function getSignaturePayload(value: SignatureValue | '' | undefined | null): string {
if (!value) return ''
if (isClassicSignature(value)) return value.payload
if (isPasskeySignature(value)) return ''
return value
}
export function getSignatureAttribution(value: SignatureValue | '' | undefined | null): SignatureAttribution | null {
if (!value || typeof value === 'string') return null
if (isPasskeySignature(value) || isClassicSignature(value)) {
return { username: value.username, signedAt: value.signedAt }
}
return null
}
export function createClassicSignature(input: {
role: 'skipper' | 'crew'
userId: string
username: string
signedAt: string
payload: string
}): ClassicSignature {
return {
kind: 'classic',
version: 1,
role: input.role,
userId: input.userId,
username: input.username,
signedAt: input.signedAt,
payload: input.payload
}
}
export function normalizeSignature(value: unknown): SignatureValue | undefined { export function normalizeSignature(value: unknown): SignatureValue | undefined {
if (value === null || value === undefined || value === '') return undefined if (value === null || value === undefined || value === '') return undefined
if (isPasskeySignature(value)) return value if (isPasskeySignature(value)) return value
if (isClassicSignature(value)) return value
if (typeof value === 'string') return value if (typeof value === 'string') return value
return undefined return undefined
} }
@@ -47,6 +95,7 @@ export async function getSkipperSignStatus(
export interface SignatureExportLabels { export interface SignatureExportLabels {
imagePlaceholder: string imagePlaceholder: string
passkeyLabel: (username: string, signedAt: string) => string passkeyLabel: (username: string, signedAt: string) => string
attributionLabel: (username: string, signedAt: string) => string
} }
export function formatSignatureForExport( export function formatSignatureForExport(
@@ -57,15 +106,19 @@ export function formatSignatureForExport(
if (isPasskeySignature(value)) { if (isPasskeySignature(value)) {
return labels.passkeyLabel(value.username, value.signedAt) return labels.passkeyLabel(value.username, value.signedAt)
} }
if (isClassicSignature(value)) {
return labels.attributionLabel(value.username, value.signedAt)
}
if (isSignatureImage(value)) return labels.imagePlaceholder if (isSignatureImage(value)) return labels.imagePlaceholder
return value return value
} }
export function serializeSignature(value: SignatureValue | '' | undefined): SignatureValue | undefined { export function serializeSignature(value: SignatureValue | '' | undefined): SignatureValue | undefined {
if (!value) return undefined if (!value) return undefined
if (isPasskeySignature(value)) return value if (isPasskeySignature(value) || isClassicSignature(value)) return value
if (isSignatureImage(value)) return value const payload = typeof value === 'string' ? value : getSignaturePayload(value)
const trimmed = value.trim() if (isSignatureImage(payload)) return payload
const trimmed = payload.trim()
return trimmed || undefined return trimmed || undefined
} }
+2 -1
View File
@@ -21,5 +21,6 @@
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/*.test.ts"]
} }
+11 -1
View File
@@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
@@ -20,6 +21,10 @@ function readAppVersion(): string {
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
test: {
environment: 'happy-dom',
include: ['src/**/*.test.ts']
},
define: { define: {
__APP_VERSION__: JSON.stringify(readAppVersion()) __APP_VERSION__: JSON.stringify(readAppVersion())
}, },
@@ -42,6 +47,9 @@ export default defineConfig({
srcDir: 'src', srcDir: 'src',
filename: 'sw.ts', filename: 'sw.ts',
registerType: 'prompt', registerType: 'prompt',
devOptions: {
enabled: false
},
includeAssets: ['favicon.ico', 'logo.png'], includeAssets: ['favicon.ico', 'logo.png'],
injectManifest: { injectManifest: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}'] globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}']
@@ -49,7 +57,9 @@ export default defineConfig({
manifest: { manifest: {
name: 'Kapteins Daagbok', name: 'Kapteins Daagbok',
short_name: 'Daagbok', short_name: 'Daagbok',
description: 'Free, ad-free maritime logbook with E2E encryption and Passkeys', lang: 'de',
description:
'Digitales Yacht-Logbuch — E2E-verschlüsselt, offline-fähig.',
theme_color: '#1e293b', theme_color: '#1e293b',
background_color: '#0f172a', background_color: '#0f172a',
display: 'standalone', display: 'standalone',
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+122 -33
View File
@@ -28,9 +28,11 @@
.page { .page {
width: 210mm; width: 210mm;
height: 297mm; height: 297mm;
padding: 14mm 16mm 12mm; max-height: 297mm;
padding: 12mm 15mm 10mm;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 5mm;
background: background:
radial-gradient(ellipse 120% 80% at 100% 0%, rgba(56, 189, 248, 0.12) 0%, transparent 55%), radial-gradient(ellipse 120% 80% at 100% 0%, rgba(56, 189, 248, 0.12) 0%, transparent 55%),
radial-gradient(ellipse 90% 60% at 0% 100%, rgba(134, 59, 255, 0.14) 0%, transparent 50%), radial-gradient(ellipse 90% 60% at 0% 100%, rgba(134, 59, 255, 0.14) 0%, transparent 50%),
@@ -52,20 +54,20 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5mm; gap: 5mm;
margin-bottom: 6mm; flex-shrink: 0;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.logo { .logo {
width: 14mm; width: 16mm;
height: 14mm; height: 16mm;
flex-shrink: 0; flex-shrink: 0;
object-fit: contain; object-fit: contain;
} }
.title-block h1 { .title-block h1 {
font-size: 22pt; font-size: 23pt;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: #f8fafc; color: #f8fafc;
@@ -73,7 +75,7 @@
} }
.title-block p { .title-block p {
font-size: 10.5pt; font-size: 12pt;
color: #94a3b8; color: #94a3b8;
margin-top: 1.5mm; margin-top: 1.5mm;
} }
@@ -83,19 +85,19 @@
align-self: flex-start; align-self: flex-start;
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b; color: #1e293b;
font-size: 9pt; font-size: 11pt;
font-weight: 800; font-weight: 800;
letter-spacing: 0.12em; letter-spacing: 0.12em;
padding: 2mm 4mm; padding: 2.5mm 4.5mm;
border-radius: 2mm; border-radius: 2mm;
text-transform: uppercase; text-transform: uppercase;
} }
.intro { .intro {
font-size: 10.5pt; font-size: 12pt;
line-height: 1.55; line-height: 1.5;
color: #cbd5e1; color: #cbd5e1;
margin-bottom: 6mm; flex-shrink: 0;
max-width: 95%; max-width: 95%;
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -105,11 +107,48 @@
color: #f8fafc; color: #f8fafc;
} }
.screenshots {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3mm;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.screenshot-card {
border-radius: 2.5mm;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(15, 23, 42, 0.55);
display: flex;
flex-direction: column;
min-width: 0;
}
.screenshot-card img {
width: 100%;
height: 50mm;
object-fit: contain;
object-position: top center;
display: block;
background: #0b1220;
}
.screenshot-caption {
font-size: 9pt;
color: #94a3b8;
text-align: center;
padding: 1.5mm 2mm;
line-height: 1.3;
flex-shrink: 0;
}
.features { .features {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 3mm 6mm; gap: 2.5mm 6mm;
margin-bottom: 6mm; flex-shrink: 0;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
@@ -118,7 +157,7 @@
display: flex; display: flex;
gap: 2.5mm; gap: 2.5mm;
align-items: flex-start; align-items: flex-start;
font-size: 9.5pt; font-size: 10.5pt;
line-height: 1.4; line-height: 1.4;
color: #e2e8f0; color: #e2e8f0;
} }
@@ -130,26 +169,53 @@
width: 4mm; width: 4mm;
} }
.lang-list {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 1.5mm;
}
.lang-item {
display: inline-flex;
align-items: center;
gap: 1.2mm;
white-space: nowrap;
}
.feature-flag {
display: inline-block;
width: 5mm;
height: 3.5mm;
border-radius: 0.3mm;
flex-shrink: 0;
box-shadow: 0 0 0 0.15mm rgba(0, 0, 0, 0.25);
}
.lang-sep {
color: #94a3b8;
}
.beta-box { .beta-box {
background: rgba(30, 41, 59, 0.85); background: rgba(30, 41, 59, 0.85);
border: 1px solid rgba(251, 191, 36, 0.35); border: 1px solid rgba(251, 191, 36, 0.35);
border-left: 3px solid #fbbf24; border-left: 3px solid #fbbf24;
border-radius: 3mm; border-radius: 3mm;
padding: 5mm 6mm; padding: 5mm 6mm;
margin-bottom: 6mm; flex-shrink: 0;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.beta-box h2 { .beta-box h2 {
font-size: 11pt; font-size: 12.5pt;
color: #fbbf24; color: #fbbf24;
margin-bottom: 2mm; margin-bottom: 2mm;
font-weight: 700; font-weight: 700;
} }
.beta-box p { .beta-box p {
font-size: 9.5pt; font-size: 10.5pt;
line-height: 1.5; line-height: 1.5;
color: #cbd5e1; color: #cbd5e1;
} }
@@ -157,12 +223,12 @@
.cta { .cta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8mm; gap: 7mm;
background: rgba(15, 23, 42, 0.6); background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.2); border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 4mm; border-radius: 4mm;
padding: 5mm 6mm; padding: 5mm 6mm;
margin-bottom: auto; flex-shrink: 0;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
@@ -183,16 +249,16 @@
} }
.cta-text h3 { .cta-text h3 {
font-size: 13pt; font-size: 14.5pt;
color: #38bdf8; color: #38bdf8;
font-weight: 700; font-weight: 700;
margin-bottom: 2mm; margin-bottom: 2mm;
} }
.cta-text p { .cta-text p {
font-size: 9pt; font-size: 11pt;
color: #94a3b8; color: #94a3b8;
line-height: 1.45; line-height: 1.5;
} }
.tags { .tags {
@@ -203,7 +269,7 @@
} }
.tag { .tag {
font-size: 7.5pt; font-size: 9.5pt;
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
@@ -216,8 +282,9 @@
footer { footer {
border-top: 1px solid rgba(148, 163, 184, 0.15); border-top: 1px solid rgba(148, 163, 184, 0.15);
padding-top: 3mm; padding-top: 3mm;
margin-top: 5mm; margin-top: auto;
font-size: 7.5pt; flex-shrink: 0;
font-size: 9.5pt;
line-height: 1.5; line-height: 1.5;
color: #64748b; color: #64748b;
position: relative; position: relative;
@@ -242,20 +309,42 @@
</header> </header>
<p class="intro"> <p class="intro">
Führe dein Bordlogbuch digital: Reisetage, GPS-Tracks, Crew und Schiffsdaten — Führe dein Bordlogbuch digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
<strong>End-to-End-verschlüsselt</strong>, als App installierbar und <strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und
<strong>auch offline</strong> auf See nutzbar. <strong>auch offline</strong> auf See nutzbar.
</p> </p>
<section class="features" aria-label="Funktionen"> <section class="features" aria-label="Funktionen">
<div class="feature"><span class="feature-icon"></span><span>Reisetage im nautischen Logbuch-Format (Hafen, Wetter, Tankstände)</span></div> <div class="feature"><span class="feature-icon"></span><span>Reisetage im nautischen Logbuch-Format (Hafen, Wetter, Besegelung, Crew, Tankstände)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Offline-fähige PWA — installierbar auf Smartphone &amp; Tablet</span></div> <div class="feature"><span class="feature-icon"></span><span>Offline-fähige PWA — läuft auf jedem Smartphone &amp; Tablet</span></div>
<div class="feature"><span class="feature-icon"></span><span>Passkey-Anmeldung &amp; clientseitige Verschlüsselung</span></div> <div class="feature"><span class="feature-icon"></span><span>Einfache passwortlose Passkey-Anmeldung</span></div>
<div class="feature"><span class="feature-icon"></span><span>GPS-Tracks (GPX/KML), Karte &amp; Streckenstatistik</span></div> <div class="feature"><span class="feature-icon"></span><span>Ende-zu-Ende Verschlüsselung</span></div>
<div class="feature"><span class="feature-icon"></span><span>GPS-Track Upload (GPX/KML) mit Kartendarstellung</span></div>
<div class="feature"><span class="feature-icon"></span><span>Streckenstatistik</span></div>
<div class="feature"><span class="feature-icon"></span><span>Foto-Anhänge pro Reisetag</span></div> <div class="feature"><span class="feature-icon"></span><span>Foto-Anhänge pro Reisetag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Foto-Anhänge für Skipper und Crew</span></div>
<div class="feature"><span class="feature-icon"></span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div> <div class="feature"><span class="feature-icon"></span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div>
<div class="feature"><span class="feature-icon"></span><span>PDF- &amp; CSV-Export, verschlüsseltes Backup</span></div> <div class="feature"><span class="feature-icon"></span><span>PDF- &amp; CSV-Export</span></div>
<div class="feature"><span class="feature-icon"></span><span>Mehrere Logbücher · Deutsch &amp; Englisch</span></div> <div class="feature"><span class="feature-icon"></span><span>Verschlüsseltes Backup &amp; Wiederherstellung</span></div>
<div class="feature"><span class="feature-icon"></span><span>Logbuch mit Freunden teilen</span></div>
<div class="feature"><span class="feature-icon"></span><span>Beliebig viele Schiffe und Logbücher</span></div>
<div class="feature"><span class="feature-icon"></span><span class="lang-list"><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">&amp;</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>Englisch</span></span></div>
<div class="feature"><span class="feature-icon"></span><span>Crafted in Kiel.Sailing.City.</span></div>
</section>
<section class="screenshots" aria-label="App-Screenshots">
<figure class="screenshot-card">
<img src="assets/screenshot-login.png" alt="Anmeldung mit Passkey und Demo" />
<figcaption class="screenshot-caption">Anmeldung &amp; Passkey</figcaption>
</figure>
<figure class="screenshot-card">
<img src="assets/screenshot-logbook.png" alt="Logbuch-Journal mit Reisetagen" />
<figcaption class="screenshot-caption">Logbuch-Journal</figcaption>
</figure>
<figure class="screenshot-card">
<img src="assets/screenshot-vessel.png" alt="Schiffs-Stammdaten mit Yachtfoto" />
<figcaption class="screenshot-caption">Schiffsdaten</figcaption>
</figure>
</section> </section>
<section class="beta-box"> <section class="beta-box">
Binary file not shown.
+296
View File
@@ -0,0 +1,296 @@
# Implementierungsplan: 360°-Kompass-Dial für Kursangaben
**Status:** Implementiert (Branch `feat/compass-course-dial`)
**Bezug:** Ereignisprotokoll (`LogEntryEditor`), Felder MgK / rwK / Windrichtung
**Vorbild im Projekt:** `EventTimeInput24h` (spezialisierte Eingabe + Text-Fallback, keine API-Änderung)
---
## 1. Ziel und Nicht-Ziele
### Ziel
- Eingabe von Kurswinkeln (0°–360°) über einen **mobil tauglichen Kompass-Ring** (Drag/Tap).
- **Hybrid-Eingabe:** Dial + numerisches Feld (wie bei der Uhrzeit).
- Einheitliche Normalisierung (`000``360`, Speicherung als String ohne `°`).
- Wiederverwendbare Komponente für **MgK**, **rwK** und optional **Wind** (Gradmodus).
### Nicht-Ziele (v1)
- Keine Änderung am Server-Schema oder Verschlüsselungsformat.
- Keine Device-Orientation / echter Kompass des Geräts (optional Phase 2).
- Kein Ersatz der Ablenkungstabelle (`DeviationForm`) bleibt 10°-Raster.
- Windrichtung bleibt **kompatibel** mit bestehenden Kardinalwerten (`N`, `NNE`, …) aus Wetter-API.
---
## 2. Ist-Analyse
| Feld | Speicherformat | UI heute | Besonderheit |
|------|----------------|----------|--------------|
| `mgk` | String, z. B. `"042"` | Text `placeholder="e.g. 180"` | Grad, PDF/CSV mit `°` |
| `rwk` | String, z. B. `"038"` | Text | Grad |
| `windDirection` | String | Text | Oft **Kardinal** (`NW`) via OpenWeather; manuell auch Grad möglich |
**Betroffene Dateien (Lesen/Schreiben, unverändert speichern):**
- `client/src/components/LogEntryEditor.tsx` Formular + Tabelle
- `client/src/utils/logEntryPayload.ts` `normalizeLogEvent`
- `client/src/services/pdfExport.ts`, `csvExport.ts` Export
- `client/src/services/demoLogbookData.ts` Demo-Daten
**Referenz-Pattern:** `EventTimeInput24h.tsx` + `parseTimeToHHMM` / `joinTimeHHMM` in `logEntryPayload.ts`.
---
## 3. Architektur
```
client/src/utils/courseAngle.ts # Parsing, Normalisierung, Winkel-Mathe
client/src/components/CourseDialInput.tsx # UI: SVG-Ring + Zahleneingabe
client/src/components/CourseDialField.tsx # Label + Fehler + Modus (optional)
client/src/App.css # .course-dial-* Styles
client/src/components/LogEntryEditor.tsx # Integration MgK/rwk/Wind
client/src/i18n/locales/{de,en}.json # Strings
```
### 3.1 Utility-Schicht `courseAngle.ts`
| Funktion | Verhalten |
|----------|-----------|
| `parseCourseAngle(value)` | `"185"`, `"185°"`, `" 042 "``185` oder `null` |
| `formatCourseAngle(degrees, pad?)` | `185``"185"` oder `"185"` / `"042"` (pad optional) |
| `normalizeCourseAngleString(value)` | Parse oder Fallback; für `normalizeLogEvent` |
| `pointerAngleToDegrees(clientX, clientY, cx, cy)` | `atan2`, 0° = Nord, Uhrzeigersinn maritim |
| `degreesToCardinal(deg)` | 16-Sektoren (bestehende Logik aus Wetter-Import) |
| `cardinalToDegrees(label)` | Reverse für Dial-Anzeige bei Kardinal-Strings |
| `snapDegrees(deg, step)` | `step` 1, 5 oder 10 |
**Konvention:** 0° = Nord, Winkel im Uhrzeigersinn (Kompass/Navigation), konsistent mit `wind.deg` in `LogEntryEditor`.
### 3.2 Komponente `CourseDialInput`
**Props:**
```ts
interface CourseDialInputProps {
value: string // roher Formularwert
onChange: (value: string) => void
disabled?: boolean
step?: 1 | 5 | 10 // Standard: 1
allowCardinal?: boolean // Wind: true → Anzeige/Export Kardinal optional
displayMode?: 'degrees' | 'cardinal' | 'auto'
'aria-label': string
id?: string
}
```
**UI-Aufbau:**
1. **SVG-Ring** (ca. 200240px Desktop, min. 160px Mobile)
- Gradmarken alle 30° (Labels 000, 030, … 330)
- Zeiger / Highlight-Bogen bei aktuellem Wert
- `touch-action: none` auf Ringfläche
2. **Zentrum:** große Anzeige `185°` oder `NW`
3. **Darunter:** `<input type="text" inputMode="numeric">` mit Validierung on blur
4. **Fein/Grob-Toggle** (optional): 1° / 5° / 10° (lokal in `sessionStorage` merken)
**Interaktion:**
- `pointerdown``setPointerCapture``pointermove` → Winkel berechnen → snappen → `onChange`
- Tap auf Ring: Winkel zum Tap-Punkt
- Tastatur am Zahleneingang: Pfeiltasten ±step (wenn fokussiert)
**Barrierefreiheit:**
- `role="slider"`, `aria-valuemin={0}`, `aria-valuemax={360}`, `aria-valuenow`, `aria-label`
- Zahleneingang bleibt voll bedienbar ohne Dial
- Fokus-Reihenfolge: Input vor Dial oder umgekehrt (Input zuerst empfohlen)
### 3.3 Windrichtung: Modus-Entscheidung
**Empfehlung v1:** Zwei Darstellungsmodi, **ein Speicher-String**:
| Modus | Speicher | Dial |
|-------|----------|------|
| Grad | `"225"` | Standard-Dial |
| Kardinal | `"SW"` | Dial zeigt Sektor-Mitte (225°), Änderung schreibt Kardinal |
- Wetter-Import (`handleFetchWeather`) setzt weiter Kardinal → Dial mappt auf Sektor.
- Nutzer kann auf Grad umschalten (kleiner Link „Als Grad“ / Toggle).
- `normalizeLogEvent`: erkennt Kardinal vs. Zahl, keine erzwungene Konvertierung beim Laden.
---
## 4. Integration `LogEntryEditor`
### 4.1 Layout (mobil-first)
**Problem:** Formular ist bereits dicht (`form-grid`).
**Lösung:** Kurs-Block als **eigene Sektion** „Kurs“ mit Tabs:
```
[ MgK ] [ rwK ] ← Tab-Leiste (Segmented Control)
┌─────────────────────────┐
│ CourseDialInput │ ← ein Dial, Wert je Tab
│ + Zahleneingang │
└─────────────────────────┘
```
- Ein Dial, State wechselt mit Tab (`activeCourseField: 'mgk' | 'rwk'`).
- Spart Platz; MgK/rwk werden nacheinander gesetzt (typischer Workflow).
**Windrichtung:** eigene Zeile unter Wetter-Grid; kompakter Dial (kleinere `size="sm"`) oder ausklappbar „Wind am Kompass setzen“.
### 4.2 Ersetzungen
| Alt | Neu |
|-----|-----|
| `<input>` MgK | `<CourseDialInput value={evMgk} … />` |
| `<input>` rwK | Tab + gleicher Dial |
| `<input>` Wind | `<CourseDialInput allowCardinal displayMode="auto" … />` |
### 4.3 `normalizeLogEvent`
```ts
mgk: normalizeCourseAngleString(e.mgk, { allowEmpty: true }),
rwk: normalizeCourseAngleString(e.rwk, { allowEmpty: true }),
windDirection: normalizeWindDirectionString(e.windDirection), // Kardinal ODER Grad-String
```
Bestehende Demo- und Export-Daten bleiben gültig.
---
## 5. Styling (`App.css`)
- `.course-dial` Container, max-width, zentriert
- `.course-dial__svg` `width: 100%; aspect-ratio: 1`
- `.course-dial__ring` stroke, hover/active
- `.course-dial__needle` transform `rotate(${deg}deg)`
- `.course-dial__value` tabular-nums, große Schrift
- `.course-dial__input` wie `.time-input-24h`
- `.course-dial-tabs` Segmented Control (bestehende `--app-accent-*` Tokens)
- **Responsive:** `@media (max-width: 640px)` Dial max min(72vw, 220px); Touch-Target Ring ≥ 44px
**Theme:** `currentColor` / CSS-Variablen (`--app-text`, `--app-accent-light`) Dark/Light via `themes.css`.
---
## 6. Internationalisierung
Neue Keys unter `logs.*`:
| Key | DE | EN |
|-----|----|----|
| `course_dial_hint` | Am Ring drehen oder Grad eingeben | Drag the ring or enter degrees |
| `course_step_fine` | 1° | 1° |
| `course_step_medium` | 5° | 5° |
| `course_step_coarse` | 10° | 10° |
| `course_tab_mgk` | MgK | MgK |
| `course_tab_rwk` | rwK | rwK |
| `course_invalid` | Ungültiger Kurs (0360) | Invalid course (0360) |
| `wind_mode_cardinal` | Kardinal | Cardinal |
| `wind_mode_degrees` | Grad | Degrees |
---
## 7. Phasen und Aufwand
### Phase A Fundament (11,5 Tage)
- [ ] `courseAngle.ts` + Unit-Tests (Vitest einrichten falls noch nicht vorhanden)
- [ ] `CourseDialInput` (nur Grad, step 1/5, Pointer + Input)
- [ ] CSS Grundlayout
- [ ] Story/manuell: isoliert in kleiner Demo-Route oder Storybook (optional)
**Akzeptanz:** Dial setzt 0360, Input synchron, Mobile Chrome/Safari getestet.
### Phase B LogEntryEditor MgK/rwk (1 Tag)
- [ ] Tab-UI MgK / rwK
- [ ] Integration, `normalizeLogEvent`
- [ ] Read-only: Dial disabled, Wert nur Anzeige
**Akzeptanz:** Ereignis speichern/laden/PDF unverändert korrekt; Skipper-Signatur-Flow unberührt.
### Phase C Windrichtung (0,51 Tag)
- [ ] `allowCardinal` / `displayMode`
- [ ] Wetter-Import kompatibel
- [ ] Toggle Kardinal ↔ Grad
**Akzeptanz:** API-Wind `NW` zeigt Dial auf NW; manuelle Grad-Eingabe möglich.
### Phase D Polish (11,5 Tage)
- [ ] Fein/Grob-Schritte + Persistenz
- [ ] Tastatur (Pfeiltasten), Fokus-Stile
- [ ] Reduzierte Bewegung (`prefers-reduced-motion`: nur Input, Dial statisch)
- [ ] Plausible-Event optional: `Course Dial Used` (nur wenn Analytics gewünscht)
- [ ] Dokumentation in `docs/plausible-events.md` falls Event
### Phase E QA & Edge Cases (0,5 Tag)
- [ ] Leerer Wert, 360 → 0 oder 360 (festlegen: **360 als Eingabe → speichern `360` oder `000`** Empfehlung: intern 0359 speichern, Anzeige 360 = 0)
- [ ] Sehr lange Formulare auf kleinen Screens (Scroll, kein Layout-Sprung)
- [ ] Offline/PWA, kein Regression bei `buildLogEntryPayload` / Signatur-Hash
**Gesamtaufwand:** ca. **45 Entwicklertage** für vollständige Implementierung inkl. Wind + A11y + QA.
---
## 8. Tests
### Unit (`courseAngle.ts`)
- Parse: `"042"`, `"360"`, `"999"` (invalid), `"NW"` (wind helper)
- `pointerAngleToDegrees` mit festen Koordinaten
- `snapDegrees(47, 5)` → 45
- `degreesToCardinal` / `cardinalToDegrees` Roundtrip
### Komponente (Testing Library)
- `onChange` bei simuliertem Pointer-Event (oder direktem `setValue` via Input)
- Disabled-State
- `aria-valuenow` aktualisiert
### Manuell / UAT
| # | Schritt | Erwartung |
|---|---------|-----------|
| 1 | Neues Ereignis, MgK am Dial auf 090 | Tabelle zeigt `90°`, PDF/CSV `90` |
| 2 | rwK per Tastatur `270` | Dial zeigt West |
| 3 | Wetter laden | Wind `NW`, Dial passend |
| 4 | iPhone Safari, Daumen-Drag | Kein Scroll-Leaken, Wert stabil |
| 5 | Nur Tastatur | Input allein speicherbar |
| 6 | Bestehenden Eintrag bearbeiten | Alte Werte korrekt im Dial |
---
## 9. Risiken und Mitigationen
| Risiko | Mitigation |
|--------|------------|
| Dial zu groß auf Mobile | Tabs + max-width; Wind einklappbar |
| Scroll vs. Drag | `touch-action: none` nur am Ring |
| Kardinal/Grad-Inkonsistenz | `displayMode="auto"`, kein Silent-Overwrite |
| Signatur-Hash ändert sich | Nur Normalisierung die bereits gültige Strings erlaubt; keine Rundung beim Speichern ohne Nutzeraktion |
| Performance bei vielen Events | Dial nur im Formular, nicht in Tabelle |
---
## 10. Optionale Erweiterungen (Post-v1)
1. **MgK → rwK aus Ablenkungstabelle** vorschlagen (Lookup `deviations[roundedMgK]`).
2. **DeviceOrientation** für Ring-Ausrichtung (mit Permission-Hinweis).
3. **Haptik** `navigator.vibrate(10)` bei Snap (Android).
4. **DeviationForm:** visueller Kompass statt nur Grid (separate Story).
---
## 11. Abnahmekriterien (Definition of Done)
- [ ] MgK und rwK im Ereignisformular per Dial + Input editierbar (Desktop + Mobile).
- [ ] Windrichtung: Dial + Kardinal/Grad kompatibel mit Wetter-Import.
- [ ] Keine Backend-/Migrations-Änderung; bestehende Logbücher laden unverändert.
- [ ] PDF/CSV/Signatur-Verhalten identisch zu heute (nur Darstellung/Eingabe verbessert).
- [ ] WCAG: Slider + Input bedienbar, `prefers-reduced-motion` berücksichtigt.
- [ ] DE/EN vollständig übersetzt.
---
## 12. Empfohlene Umsetzungsreihenfolge (Commits)
1. `feat(course): add courseAngle utilities and tests`
2. `feat(course): add CourseDialInput component and styles`
3. `feat(logs): integrate compass dial for MgK and rwK`
4. `feat(logs): wind direction dial with cardinal support`
5. `fix(logs): a11y and reduced-motion for course dial`
6. `docs: compass course dial plan and plausible event` (optional)
+17 -2
View File
@@ -29,6 +29,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Demo Opened | Public-Demo unter `/demo` geöffnet (`DemoViewer.tsx`) | — | | Demo Opened | Public-Demo unter `/demo` geöffnet (`DemoViewer.tsx`) | — |
| Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — | | Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — |
| Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — | | Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — |
| Logbook Shared | Öffentlicher Freigabelink aktiviert (`SettingsForm.tsx`) | — |
| Public Link Opened | Freigabelink unter `/share` erfolgreich geladen (`ReadOnlyViewer.tsx`) | — |
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` | | PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — | | CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — | | CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
@@ -38,12 +40,23 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — | | Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — | | Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — | | Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
| Profile Opened | Profilseite geöffnet (`UserProfilePage.tsx`, einmal pro Mount) | — |
| Passkey Added | Passkey erfolgreich registriert (`UserProfilePage.tsx`) | `labeled`: `true` \| `false` (optionaler Name gesetzt) |
| Passkey Removed | Passkey entfernt, mindestens ein Key verbleibt (`UserProfilePage.tsx`) | — |
| Passkey Renamed | Passkey-Name gespeichert (`UserProfilePage.tsx`) | — |
| Last Passkey Remove Hinted | Löschen des einzigen Passkeys abgebrochen — Hinweisdialog zur Kontolöschung (`UserProfilePage.tsx`) | — |
| Local PIN Set | Lokaler PIN gesetzt oder geändert (`UserProfilePage.tsx`) | `action`: `set` \| `change` |
| Local PIN Removed | Lokaler PIN entfernt (`UserProfilePage.tsx`) | — |
| Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — |
| Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`UserProfilePage.tsx`) | — |
## Bewusst nicht getrackt ## Bewusst nicht getrackt
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen. - **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. - **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties. - **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
- **Profil-KPIs:** Statistik-Karten und User-ID-Kopieren werden nicht getrackt (reine Anzeige bzw. zu granular).
- **Kontolöschung:** `Account Deleted` bleibt in `auth.ts` — unabhängig davon, ob die Gefahrenzone auf der Profilseite oder früher in den Einstellungen genutzt wurde.
## Typische Funnels (Plausible Goals) ## Typische Funnels (Plausible Goals)
@@ -52,8 +65,10 @@ Empfohlene Goal-Ketten für Auswertung:
1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved 1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped) 2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
3. **Kollaboration:** Invite Generated → Invite Accepted 3. **Kollaboration:** Invite Generated → Invite Accepted
4. **Export:** Travel Day Saved → PDF Exported / CSV Exported 4. **Öffentliche Freigabe:** Logbook Shared → Public Link Opened
5. **Datensicherung:** Backup Exported → Backup Restored 5. **Export:** Travel Day Saved → PDF Exported / CSV Exported
6. **Datensicherung:** Backup Exported → Backup Restored
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
## Entwicklung ## Entwicklung
+1
View File
@@ -52,6 +52,7 @@ model Credential {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
credentialId String @unique credentialId String @unique
label String?
publicKey Bytes publicKey Bytes
counter BigInt counter BigInt
transports String[] // WebAuthn transports list transports String[] // WebAuthn transports list
+269
View File
@@ -22,8 +22,22 @@ const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173' const origin = process.env.ORIGIN || 'http://localhost:5173'
const registrationChallenges = new Map<string, string>() const registrationChallenges = new Map<string, string>()
/** WebAuthn registration challenges for add-credential flow: challenge -> userId */
const addCredentialChallenges = new Map<string, string>()
const activeChallenges = new Set<string>() const activeChallenges = new Set<string>()
function previewCredentialId(credentialId: string): string {
if (credentialId.length <= 16) return credentialId
return `${credentialId.slice(0, 8)}${credentialId.slice(-8)}`
}
function normalizeCredentialLabel(label: unknown): string | null {
if (typeof label !== 'string') return null
const trimmed = label.trim()
if (!trimmed) return null
return trimmed.slice(0, 64)
}
router.post('/register-options', async (req, res) => { router.post('/register-options', async (req, res) => {
try { try {
const { username } = req.body const { username } = req.body
@@ -381,4 +395,259 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => {
} }
}) })
router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
try {
const { encryptedMasterKeyRec, encryptedMasterKeyRecIv, encryptedMasterKeyRecTag } = req.body
if (!encryptedMasterKeyRec || !encryptedMasterKeyRecIv || !encryptedMasterKeyRecTag) {
return res.status(400).json({ error: 'Missing required recovery key fields' })
}
if (
typeof encryptedMasterKeyRec !== 'string' ||
typeof encryptedMasterKeyRecIv !== 'string' ||
typeof encryptedMasterKeyRecTag !== 'string'
) {
return res.status(400).json({ error: 'Invalid recovery key fields format' })
}
await prisma.user.update({
where: { id: req.userId },
data: {
encryptedMasterKeyRec,
encryptedMasterKeyRecIv,
encryptedMasterKeyRecTag
}
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error rotating recovery key:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.get('/profile', requireUser, async (req: any, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.userId },
include: {
credentials: {
orderBy: { id: 'asc' }
},
_count: {
select: {
logbooks: true,
collaborations: true
}
}
}
})
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
return res.json({
userId: user.id,
username: user.username,
createdAt: user.createdAt.toISOString(),
hasPrfEncryption: user.encryptedMasterKeyPrf != null,
credentials: user.credentials.map((cred) => ({
id: cred.id,
label: cred.label,
credentialIdPreview: previewCredentialId(cred.credentialId),
transports: cred.transports
})),
serverMeta: {
ownedLogbookCount: user._count.logbooks,
collaborationCount: user._count.collaborations
}
})
} catch (error: any) {
console.error('Error fetching user profile:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.post('/add-credential-options', requireReauth, async (req: any, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.userId },
include: { credentials: true }
})
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
const userID = Buffer.from(user.username, 'utf8').toString('base64url')
const excludeCredentials = user.credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key' as const,
transports: cred.transports as any[]
}))
const options = await generateRegistrationOptions({
rpName,
rpID,
userID,
userName: user.username,
userDisplayName: user.username,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred'
},
supportedAlgorithmIDs: [-7, -257],
excludeCredentials
})
addCredentialChallenges.set(options.challenge, req.userId)
return res.json(options)
} catch (error: any) {
console.error('Error generating add-credential options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
try {
const { credentialResponse, challenge } = req.body
if (!credentialResponse || !challenge) {
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
}
const label = normalizeCredentialLabel(req.body.label)
const challengeUserId = addCredentialChallenges.get(challenge)
if (!challengeUserId) {
return res.status(400).json({ error: 'Challenge not found or expired' })
}
if (challengeUserId !== req.userId) {
return res.status(403).json({ error: 'Challenge does not belong to this account' })
}
// Single-use: invalidate before verification so failed attempts cannot be retried
addCredentialChallenges.delete(challenge)
const user = await prisma.user.findUnique({
where: { id: req.userId }
})
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
const verification = await verifyRegistrationResponse({
response: credentialResponse,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpID
})
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ error: 'WebAuthn verification failed' })
}
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo
const credentialId = Buffer.from(credentialID).toString('base64url')
const existing = await prisma.credential.findUnique({
where: { credentialId }
})
if (existing) {
return res.status(400).json({ error: 'Credential already registered' })
}
const credential = await prisma.credential.create({
data: {
userId: req.userId,
credentialId,
label,
publicKey: Buffer.from(credentialPublicKey),
counter: BigInt(counter),
transports: credentialResponse.response.transports || []
}
})
return res.json({
verified: true,
credential: {
id: credential.id,
label: credential.label,
credentialIdPreview: previewCredentialId(credential.credentialId),
transports: credential.transports
}
})
} catch (error: any) {
console.error('Error verifying add-credential response:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.patch('/credentials/:id', requireReauth, async (req: any, res) => {
try {
const { id } = req.params
const label = normalizeCredentialLabel(req.body?.label)
const credential = await prisma.credential.findUnique({
where: { id }
})
if (!credential || credential.userId !== req.userId) {
return res.status(404).json({ error: 'Credential not found' })
}
const updated = await prisma.credential.update({
where: { id },
data: { label }
})
return res.json({
credential: {
id: updated.id,
label: updated.label,
credentialIdPreview: previewCredentialId(updated.credentialId),
transports: updated.transports
}
})
} catch (error: any) {
console.error('Error updating credential label:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.delete('/credentials/:id', requireReauth, async (req: any, res) => {
try {
const { id } = req.params
const credential = await prisma.credential.findUnique({
where: { id }
})
if (!credential || credential.userId !== req.userId) {
return res.status(404).json({ error: 'Credential not found' })
}
const credentialCount = await prisma.credential.count({
where: { userId: req.userId }
})
if (credentialCount <= 1) {
return res.status(400).json({ error: 'Cannot remove the last passkey' })
}
await prisma.credential.delete({
where: { id }
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error deleting credential:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
export default router export default router
+37
View File
@@ -131,4 +131,41 @@ router.delete('/:id', async (req: any, res) => {
} }
}) })
// 5. Update a logbook title
router.put('/:id', async (req: any, res) => {
try {
const { id } = req.params
const { encryptedTitle } = req.body
if (!encryptedTitle) {
return res.status(400).json({ error: 'encryptedTitle is required' })
}
const logbook = await prisma.logbook.findUnique({
where: { id }
})
if (!logbook) {
return res.status(404).json({ error: 'Logbook not found' })
}
if (logbook.userId !== req.userId) {
return res.status(403).json({ error: 'Forbidden: Access denied' })
}
const updatedLogbook = await prisma.logbook.update({
where: { id },
data: {
encryptedTitle,
updatedAt: new Date()
}
})
return res.json(updatedLogbook)
} catch (error: any) {
console.error('Error updating logbook:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
export default router export default router
+36 -13
View File
@@ -57,6 +57,13 @@ function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: stri
return access.isOwner || access.collaboration?.role === 'WRITE' return access.isOwner || access.collaboration?.role === 'WRITE'
} }
async function hasWriteCollaborators(logbookId: string): Promise<boolean> {
const count = await prisma.collaboration.count({
where: { logbookId, role: 'WRITE' }
})
return count > 0
}
async function getAllowCredentialsForRole( async function getAllowCredentialsForRole(
logbookId: string, logbookId: string,
role: 'skipper' | 'crew', role: 'skipper' | 'crew',
@@ -79,7 +86,16 @@ async function getAllowCredentialsForRole(
}) })
const userIds = collaborations.map((c) => c.userId) const userIds = collaborations.map((c) => c.userId)
if (userIds.length === 0) return [] if (userIds.length === 0) {
const credentials = await prisma.credential.findMany({
where: { userId: requestingUserId }
})
return credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key' as const,
transports: cred.transports as any[]
}))
}
const credentials = await prisma.credential.findMany({ const credentials = await prisma.credential.findMany({
where: { userId: { in: userIds } } where: { userId: { in: userIds } }
@@ -99,14 +115,7 @@ async function isAuthorizedSigner(
role: 'skipper' | 'crew' role: 'skipper' | 'crew'
): Promise<boolean> { ): Promise<boolean> {
if (role === 'skipper') { if (role === 'skipper') {
// Skipper signing: owner or WRITE collaborator (design §2.1), using their own passkey. return signerUserId === ownerUserId
if (signerUserId === ownerUserId) return true
const collaboration = await prisma.collaboration.findUnique({
where: {
logbookId_userId: { logbookId, userId: signerUserId }
}
})
return collaboration?.role === 'WRITE'
} }
const collaboration = await prisma.collaboration.findUnique({ const collaboration = await prisma.collaboration.findUnique({
@@ -114,7 +123,13 @@ async function isAuthorizedSigner(
logbookId_userId: { logbookId, userId: signerUserId } logbookId_userId: { logbookId, userId: signerUserId }
} }
}) })
return collaboration?.role === 'WRITE' if (collaboration?.role === 'WRITE') return true
if (signerUserId === ownerUserId) {
return !(await hasWriteCollaborators(logbookId))
}
return false
} }
router.post('/options', async (req: any, res) => { router.post('/options', async (req: any, res) => {
@@ -138,6 +153,16 @@ router.post('/options', async (req: any, res) => {
return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' }) return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' })
} }
const authorized = await isAuthorizedSigner(
logbookId,
access.logbook.userId,
req.userId,
role
)
if (!authorized) {
return res.status(403).json({ error: 'Forbidden: Signer not authorized for this role' })
}
const allowCredentials = await getAllowCredentialsForRole( const allowCredentials = await getAllowCredentialsForRole(
logbookId, logbookId,
role, role,
@@ -146,9 +171,7 @@ router.post('/options', async (req: any, res) => {
if (allowCredentials.length === 0) { if (allowCredentials.length === 0) {
return res.status(400).json({ return res.status(400).json({
error: role === 'crew' error: 'No passkey credentials found for signer'
? 'No write collaborators with passkeys found'
: 'No passkey credentials found for signer'
}) })
} }
+15 -4
View File
@@ -46,7 +46,7 @@ router.post('/push', async (req: any, res) => {
// Authorize: Check if logbook belongs to user // Authorize: Check if logbook belongs to user
// Exception: If action is create logbook, the logbook might not exist yet, // Exception: If action is create logbook, the logbook might not exist yet,
// so we authorize based on user creating a logbook with their userId. // so we authorize based on user creating a logbook with their userId.
if (type === 'logbook' && action === 'create') { if (type === 'logbook' && (action === 'create' || action === 'update')) {
const existing = await prisma.logbook.findUnique({ const existing = await prisma.logbook.findUnique({
where: { id: logbookId } where: { id: logbookId }
}) })
@@ -69,9 +69,9 @@ router.post('/push', async (req: any, res) => {
}, },
update: { update: {
encryptedTitle: parsed.encryptedTitle, encryptedTitle: parsed.encryptedTitle,
encryptedKey: parsed.encryptedKey || null, ...(parsed.encryptedKey !== undefined ? { encryptedKey: parsed.encryptedKey } : {}),
iv: parsed.iv || null, ...(parsed.iv !== undefined ? { iv: parsed.iv } : {}),
tag: parsed.tag || null, ...(parsed.tag !== undefined ? { tag: parsed.tag } : {}),
updatedAt: itemUpdatedAt updatedAt: itemUpdatedAt
} }
}) })
@@ -121,6 +121,17 @@ router.post('/push', async (req: any, res) => {
continue continue
} }
if (!isOwner && (type === 'yacht' || (type === 'crew' && payloadId === 'skipper'))) {
results.push({
payloadId,
status: 'error',
error: type === 'yacht'
? 'Forbidden: Only owner can modify vessel data'
: 'Forbidden: Only owner can modify skipper profile'
})
continue
}
if (action === 'delete') { if (action === 'delete') {
if (type === 'yacht') { if (type === 'yacht') {
await prisma.yachtPayload.deleteMany({ where: { logbookId } }) await prisma.yachtPayload.deleteMany({ where: { logbookId } })