Compare commits

...

116 Commits

Author SHA1 Message Date
elpatron cc87b0f8e6 chore: release v0.1.0.67 2026-05-31 14:46:45 +02:00
elpatron 58984594b0 fix(pwa): Pending-Refresh bei Suppression nicht sofort verwerfen
Suppression wird vor dem Flush geprüft, damit gepufferte
needRefresh-Werte erhalten bleiben, bis die Unterdrückung endet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:46:02 +02:00
elpatron 61675e1085 fix(pwa): Ausstehendes needRefresh nicht mehr während des Renders setzen
Frühe Service-Worker-Callbacks puffern den Refresh-Status; der Flush
erfolgt jetzt im useEffect statt in der Render-Phase.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:44:12 +02:00
elpatron 2082218f78 chore: release v0.1.0.66 2026-05-31 14:43:25 +02:00
elpatron 5882edcbdf fix(dashboard): Sortierbuttons als kompakte Icon-Buttons ohne Text
Beschriftungen entfernt, Buttons auf 36px verkleinert; aria-label und
title bleiben für Tooltip und Barrierefreiheit erhalten.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:41:46 +02:00
elpatron b7a47a1d90 fix(ui): Vite-Template-CSS entfernen und App-Layout wieder zentrieren
index.css auf App-Shell reduziert, App.css zentral in main.tsx geladen
und #root zentriert Dashboard/Logbuch-Ansichten nach dem Login.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:40:03 +02:00
elpatron 48c408302f fix(pwa): Hintergrund-SW-Updates während Update-Suppression unterbinden
Das periodische Intervall nutzt checkForUpdate() und respektiert damit
„Später“ wie Fokus- und Online-Checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:28:01 +02:00
elpatron 2b5c5d4a36 feat(dashboard): Filter und Sortierung für die Logbuchliste
Hilft bei vielen Logbüchern: Suche nach Name/Jahr/Datum sowie Sortierung nach Name oder Datum in beide Richtungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:27:41 +02:00
elpatron 7cf04b3357 fix(pwa): Recovery-Zähler, Update-Suppression und Timer-Leaks beheben
Stale-Recovery zählt nur aufeinanderfolgende Fehler und wird nach Hard Recovery zurückgesetzt; Update-Checks respektieren „Später“, und PWA-Refresh-State sowie Recovery-Timer werden zuverlässig gesetzt bzw. beim Unmount aufgeräumt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:24:25 +02:00
elpatron bbd4281dcb fix(pwa): Updates zuverlässiger erkennen und veraltete Instanzen automatisch reparieren
Unabhängige version.json-Prüfung, häufigere Update-Checks und Hard Recovery
beheben hängende Android-PWAs ohne manuelles Cache-Löschen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:20:54 +02:00
elpatron d2833f7664 chore: release v0.1.0.65 2026-05-31 14:14:25 +02:00
elpatron 2a14080b5b fix(appearance): Theme-Sync an aufgelöste User-ID und Session koppeln
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:13:09 +02:00
elpatron 2457fa41e3 fix(profile): Profil-Statistiken auf Mobilgeräten platzsparender gestalten
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:11:57 +02:00
elpatron 87b0fa7bde chore: release v0.1.0.64 2026-05-31 14:03:27 +02:00
elpatron d90f292a21 fix(appearance): Theme-Einstellungen serverseitig speichern und beim Login wiederherstellen
Nach PWA-Cache-Löschung gingen Theme und Farbschema verloren, weil sie nur in localStorage lagen. Die Präferenzen werden jetzt synchronisiert und nach dem Login erneut angewendet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:03:17 +02:00
elpatron 9e42f828a0 chore: release v0.1.0.63 2026-05-31 14:00:00 +02:00
elpatron 4197e77b1e feat(auth): Passwortmanager für Wiederherstellungsschlüssel aktivieren
Das Eingabefeld nutzt jetzt Passwort-Semantik und Autocomplete-Attribute, damit OS-Passwortmanager gespeicherte Schlüssel vorschlagen können.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:59:50 +02:00
elpatron 1373c11de8 fix(pwa): Kaltstart nach verpassten Updates stabilisieren
Service Worker übernimmt Updates zuverlässig (SKIP_WAITING, clientsClaim),
wartende Versionen werden beim Start angewendet und veraltete Chunks führen
nicht mehr zum Hängenbleiben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:58:35 +02:00
elpatron 0bae3b29dc feat: Grauwasserstand beim neuen Reisetag vom Vortag übernehmen
Übernimmt den Grauwasser-Füllstand analog zu Frischwasser und Kraftstoff
beim Anlegen eines Reisetags und zeigt ihn im Übernahme-Dialog an.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:54:57 +02:00
elpatron 73e86d28b3 chore: release v0.1.0.62 2026-05-31 13:47:49 +02:00
elpatron ad4721e694 fix: Fehler-Alert nach Push-Aktivierung korrekt awaiten
Stellt sicher, dass die Fehlermeldung angezeigt wird, bevor
promptPushAfterInviteCreated zurückkehrt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:47:36 +02:00
elpatron 8037b3b63e chore: release v0.1.0.61 2026-05-31 13:45:05 +02:00
elpatron c4cd566da0 fix: Tank-Nachfüll-Clamping und Deaktivierung bei vollem Tank
Korrigiert fehlende useEffect-Dependencies beim Auto-Clamping und
deaktiviert Refill-Eingaben, wenn der Morgenstand die Tankkapazität erreicht.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:44:49 +02:00
elpatron 3a267905b0 feat: Push-Hinweis nach Erstellen eines Crew-Einladungslinks
Owner sieht einen Dialog zur Aktivierung von Crew-Push-Benachrichtigungen, sofern diese noch nicht aktiv sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:43:52 +02:00
elpatron c856c2e903 chore: release v0.1.0.60 2026-05-31 13:41:29 +02:00
elpatron b3256d1685 fix: Tank-Slider für Touch auf Mobilgeräten vergrößern
Größerer Thumb und sichtbarere Spur mit touch-action, damit Füllstände am Handy leichter einstellbar sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:40:59 +02:00
elpatron 23fc940324 feat: dynamische Slider-Obergrenzen für Frischwasser und Treibstoff
Nachgefüllt ist auf Restkapazität nach Morgen begrenzt, Stand abends auf Morgen plus Nachgefüllt; Werte werden bei Änderungen automatisch gekürzt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:39:46 +02:00
elpatron 25e1bdded3 feat: Tankkapazitäten, Grauwasser und Slider im Journal
Schiffsdaten speichern optionale Tankvolumina; Reisetage erfassen Grauwasser-Füllstand und nutzen Slider bei bekannter Kapazität, inkl. Tooltips und CSV/PDF-Export.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:33:40 +02:00
elpatron 6a61c9e06c chore: release v0.1.0.59 2026-05-31 13:25:11 +02:00
elpatron d3683ad6aa fix: DemoViewer an erweiterte TourNavigation-Schnittstelle anpassen
Ergänzt No-Op-Stubs für setLogbookActive und setProfileOpen, damit der
Production-Build nach der Profil-Tour-Erweiterung wieder durchläuft.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:25:04 +02:00
elpatron ef5891ba3f chore: release v0.1.0.58 2026-05-31 13:22:59 +02:00
elpatron d25095bab3 fix: Fehlalarm bei sauberem Working Tree in update-prod.sh vermeiden
Die Clean-Tree-Prüfung verlässt sich nur noch auf git status --porcelain,
da git diff-index nach git reset fälschlich Änderungen melden kann.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:22:52 +02:00
elpatron 0d16782001 fix: Onboarding-Tour bei gelöschtem Demo-Logbuch und GPS-Schritt stabilisieren
Bereinigt veraltete Demo-Referenzen, löst gültiges Logbuch und ersten Eintrag zur Laufzeit auf und scrollt den GPS-Track-Schritt automatisch ins Viewport.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:15:53 +02:00
elpatron b7e2d470a9 fix: Tour-Tooltip auf feste Breite begrenzen
Entfernt left+right-Stretching in CSS, positioniert das Tooltip horizontal
am Spotlight und misst Ziele nach Navigation mit verzögerten Retries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:08:31 +02:00
elpatron 520ba766a3 feat: Onboarding-Tour um Benutzerprofil erweitern
Profil-Schritte auf dem Dashboard und in den Einstellungen, entry_open
highlightet nur die Karte ohne Editor, Finish verweist auf Benutzerprofil.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:04:02 +02:00
elpatron c215cd8b15 docs: Beta-Flyer-Formulierung für Skipper/Crew-Fotos präzisieren
Feature-Text nennt Avatarbilder statt Foto-Anhänge; PDF entsprechend aktualisiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:57:23 +02:00
elpatron 27c780d2b8 fix: Passkey-Signatur beim Speichern der Logbuchseite erhalten
Leeres Event-Formular (nur Uhrzeit) galt fälschlich als Änderung und
invalidierte frische Signaturen. Speichern-Button und Hash-Sperre folgen
nun echten Entwürfen und synchronisiertem Seiteninhalt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:57:02 +02:00
elpatron aa52948ddc chore: release v0.1.0.57 2026-05-31 12:45:44 +02:00
elpatron 49b4e7b9c3 fix: Code- und Profil-Kontrast an App-Theme binden
Benutzer-ID und Passkey-IDs nutzen jetzt Theme-Token statt System-
prefers-color-scheme, damit Monospace-Text in allen Schemes lesbar bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:43:27 +02:00
elpatron 2d64987ada chore: release v0.1.0.56 2026-05-31 12:38:09 +02:00
elpatron 87973eaa4a fix: Light-Theme-Hintergrund auf PWA/Android reparieren
Der hardcodierte Inline-Style auf body überschrieb --app-body-bg und ließ
hellen Modus mit dunklem Seitenhintergrund erscheinen. Theme-Bootstrap und
dynamisches theme-color ergänzen alle Scheme/Theme-Kombinationen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:38:01 +02:00
elpatron 93e26b7807 chore: release v0.1.0.55 2026-05-31 12:26:55 +02:00
elpatron 814eeadd1f fix: Sync-Indikator Listener-Cleanup und CSS-Zustände
useSyncIndicator gibt die Unsubscribe-Funktion von subscribeToSyncState
zurück. conn-status-Klassen berücksichtigen jetzt auch den aktiven
Sync-Lauf (syncing) statt nur die Queue-Länge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:26:33 +02:00
elpatron d9cbcd8e43 chore: release v0.1.0.54 2026-05-31 12:24:01 +02:00
elpatron 282e7ba8ba fix: Sync-Icon nur während aktiver Synchronisation animieren
Die Drehung hing an der Queue-Länge statt am laufenden Sync. Veraltete
Queue-Einträge werden nach Pull bereinigt; parallele syncAll-Läufe
werden im Sync-State korrekt gezählt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:23:50 +02:00
elpatron b86e5a15d6 chore: release v0.1.0.53 2026-05-31 12:16:02 +02:00
elpatron eac86ec655 README: Neue Features und klare Trennung Profil vs. Logbuch.
Dokumentiert Kompass-Dial, Benutzerprofil, Feedback/Ntfy, Demo-URL, Tests und aktualisierte Env-Variablen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:15:46 +02:00
elpatron a6331bea1a OWM-API-Schlüssel explizit über aktive User-ID laden.
Wetter-Abruf nutzt getOwmApiKeyForActiveUser(), damit namespaced Keys nicht am fehlenden active_userid vorbeilaufen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:14:43 +02:00
elpatron ae89b131a1 chore: release v0.1.0.52 2026-05-31 12:12:15 +02:00
elpatron 3fdea31c4a Update beta flyer styles: Adjust feature gap to 1.8mm and line height to 1.28 for improved layout consistency. Update PDF file to reflect changes. 2026-05-31 12:09:34 +02:00
elpatron 04d114c315 Marketing-Flyer: Themes mit Hell/Dunkel-Varianten erwähnen.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:07:37 +02:00
elpatron 3fa66f044c Korrigiere Label-Ausrichtung im Backup-Panel.
Labels waren durch text-align:center auf #root zentriert, Inputs linksbündig. Formularfelder nutzen nun block-Labels und konsistenten Abstand.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:06:18 +02:00
elpatron a84c611402 Verschiebe benutzerbezogene Einstellungen ins Benutzerprofil.
Theme, Farbschema, OWM-Schlüssel, Push, PWA und App-Tour liegen nun im Profil mit pro-User-localStorage. Der Logbuch-Tab fokussiert Teilen, Backup und Crew.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:01:08 +02:00
elpatron f12b9b2a1a chore: release v0.1.0.51 2026-05-31 11:52:20 +02:00
elpatron 34914b4f19 fix(ui): Segel-Picker-Layout und Formular-Reset korrigieren
Flex-Layout für Pills und Toggle wiederherstellen und den Einklapp-Zustand beim Leeren des Ereignisformulars zurücksetzen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:52:10 +02:00
elpatron d9fa8c0edf chore: release v0.1.0.50 2026-05-31 11:50:39 +02:00
elpatron adf02acd45 fix(ui): Segel-Picker auf Mobile weiter verdichten
Einklappbare Badge-Liste bei vielen Segeln, kompaktere Pills und aktive Auswahl bleibt oben sichtbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:50:23 +02:00
elpatron 3992db9d61 fix(ui): Besegelungs-Badges auf Mobile platzsparender anordnen
Die Segel-Pills nutzen die volle Formularbreite und wrappen kompakt, statt in der halben Grid-Spalte untereinander zu stapeln.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:48:30 +02:00
elpatron 51f6a1b291 chore: release v0.1.0.49 2026-05-31 11:42:37 +02:00
elpatron 0b07d8b3d3 feat(auth): Hilfe-Button auf Login öffnet Hinweise-Modal
Ersetzt den toten #help-Link durch einen Button, der dasselbe
Hinweise- und Haftungsausschluss-Modal wie in der App anzeigt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:42:28 +02:00
elpatron a07e033e62 copy(i18n): Login-Tagline persönlicher formulieren
„Dein sicheres …“ statt „Sicheres …“ auf der Anmeldeseite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:40:06 +02:00
elpatron bbe63dfb47 chore: release v0.1.0.48 2026-05-31 11:38:03 +02:00
elpatron 57f63ad486 fix(auth): Session-Restore erst mit vollständiger lokaler Session
Stellt hasUnlockedLocalSession für UI-Wiederherstellung und
enforceUnlockedSession wieder her; persistSessionUserId setzt userId
nur bei Angabe in der Server-Antwort.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:37:50 +02:00
elpatron 728c40f936 chore: release v0.1.0.47 2026-05-31 11:35:51 +02:00
elpatron 72cbad8d5e fix(auth): Session-Wiederherstellung nicht an active_userid koppeln
Trennt hasUnlockedLocalCrypto (Master-Key + Username) von
hasUnlockedLocalSession (+ userId für API), damit ein gültiges
Server-Cookie ohne userId in der Antwort keinen fälschlichen Logout auslöst.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:35:17 +02:00
elpatron 15f2172a38 fix: ungenutzten getActiveMasterKey-Import entfernen
Behebt TS6133 und schlägt fehlenden Docker-Frontend-Build wieder frei.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:34:01 +02:00
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
98 changed files with 9211 additions and 999 deletions
+50 -12
View File
@@ -2,7 +2,7 @@
Digitales Yacht-Logbuch als Progressive Web App (PWA) — **kostenlos**, **werbefrei**, offline-fähig, End-to-End-verschlüsselt und mit Passkey-Anmeldung.
**Live:** [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
**Live:** [kapteins-daagbok.eu](https://kapteins-daagbok.eu) · **Demo:** [kapteins-daagbok.eu/demo](https://kapteins-daagbok.eu/demo)
## Überblick
@@ -15,19 +15,29 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
- **Passkey-Authentifizierung** (WebAuthn) mit optionaler Recovery-Phrase und lokalem PIN-Fallback
- **Mehrere Logbücher** pro Benutzerkonto — eigene Logbücher und per Einladung geteilte Logbücher (Crew-Zugang) klar getrennt
- **Reisetage** mit Hafen, Wetter, Tankständen, Ereignissen und Tagesnummer
- **Kompass-Dial** für MgK- und RwK-Kurse — Ring-Eingabe, Gradfeld, Schrittweite 1°/5°/10° (maritime Orientierung: 0° = Nord)
- **GPS-Tracks** (GPX/KML/GeoJSON-Upload, Karte, Statistiken)
- **Foto-Anhänge** pro Reisetag
- **Passkey-Signaturen** für Skipper und Crew (hybride elektronische Signatur)
- **Schiffsdaten** und **Crew-Profile** (Skipper + Mitglieder)
- **Statistik-Dashboard** — Strecken, Verbrauch, Segel/Motor, Hafenkette (pro Logbuch oder accountweit)
- **Benutzerprofil** — kontoweite Einstellungen: Darstellung (Theme, Hell/Dunkel), OpenWeatherMap-API-Key, Web Push, PWA-Installation, Onboarding-Tour, Passkey-Verwaltung, Account-Statistik
- **Kollaboration** — Crew per Einladungslink einladen (Schreib- oder Lesezugriff)
- **Push-Benachrichtigungen** (optional) — Logbuch-Eigner per Web Push informieren, wenn Crew Änderungen synchronisiert (ohne Klartext-Inhalte; Opt-in in den Einstellungen)
- **Push-Benachrichtigungen** (optional) — Logbuch-Eigner per Web Push informieren, wenn Crew Änderungen synchronisiert (ohne Klartext-Inhalte; Opt-in im Benutzerprofil)
- **Read-only-Freigabe** — öffentlicher Lese-Link für Dritte
- **Export** — PDF pro Reisetag, CSV-Download/-Teilen
- **Backup & Wiederherstellung** — vollständiges verschlüsseltes Logbuch-Backup (Einträge, Fotos, GPS, Crew, Schiff) als `.daagbok.json`; Restore auf gleichem oder neuem Account
- **Feedback** — Bug-, Feature- und allgemeine Rückmeldungen aus der App (serverseitig via [Ntfy](https://ntfy.sh) oder self-hosted)
- **PWA** — installierbar auf iOS/Android, Offline-Modus, Update-Hinweise
- **Mehrsprachig** — Deutsch und Englisch
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer (auch unter `/demo` ohne Anmeldung)
### Benutzerprofil vs. Logbuch-Einstellungen
| Bereich | Inhalt |
|---------|--------|
| **Benutzerprofil** | Theme, Farbschema, Wetter-API-Key, Push, PWA, Tour, Passkeys, Account löschen |
| **Logbuch-Einstellungen** | Crew-Einladungen, öffentliche Freigabe, Backup & Wiederherstellung (nur Eigner) |
## Architektur
@@ -48,6 +58,7 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
| Auth | WebAuthn (Passkeys) + signiertes HttpOnly-Session-Cookie (`daagbok_session`) |
| Krypto | Web Crypto API (AES-GCM), BIP39 Recovery |
| Push (optional) | Web Push (VAPID), Custom Service Worker (`injectManifest`) |
| Feedback (optional) | Ntfy (HTTP Publish) |
### Rollen & Zugriff
@@ -73,7 +84,7 @@ Skipper- und Crew-Profile im Logbuch sind **Inhaltsdaten** (verschlüsselt), nic
## Backup & Wiederherstellung
Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
Nur der **Logbuch-Eigner** kann unter **Logbuch-Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
1. Backup-Passphrase wählen (min. 8 Zeichen, getrennt von der Datei aufbewahren)
2. Download als `.daagbok.json` — enthält alle verschlüsselten Payloads inkl. **Fotos** und GPS-Tracks
@@ -83,7 +94,7 @@ Vor dem Löschen eines Logbuchs weist die App auf diese Funktion hin. Crew-Einla
## Push-Benachrichtigungen (optional)
Logbuch-**Eigner** können unter **Einstellungen** Web Push aktivieren. Sobald ein eingeladenes Crewmitglied mit Schreibrechten (`WRITE`) Änderungen synchronisiert, erhält der Owner eine **generische** Benachrichtigung — der Server kennt keine Logbuch-Inhalte (Zero-Knowledge).
Logbuch-**Eigner** können im **Benutzerprofil** Web Push aktivieren. Sobald ein eingeladenes Crewmitglied mit Schreibrechten (`WRITE`) Änderungen synchronisiert, erhält der Owner eine **generische** Benachrichtigung — der Server kennt keine Logbuch-Inhalte (Zero-Knowledge).
| Aspekt | Verhalten |
|--------|-----------|
@@ -102,20 +113,32 @@ Schlüssel erzeugen: `npx web-push generate-vapid-keys` (im `server/`-Verzeichni
Ausführlicher Implementierungs- und Testplan: [docs/push-notifications-plan.md](docs/push-notifications-plan.md).
## Feedback (optional)
Eingeloggte Nutzer können über das Feedback-Formular in der App Rückmeldungen senden. Der Server leitet sie an einen **Ntfy**-Topic weiter (kein Klartext-Logbuch auf dem Server).
| Variable | Bedeutung |
|----------|-----------|
| `NTFY_SERVER` | Basis-URL (Standard: `https://ntfy.sh`) |
| `NTFY_TOPIC` | Topic-Name (ohne URL) |
| `NTFY_TOKEN` | Optional: Access-Token für geschützte Topics |
Ohne `NTFY_TOPIC` antwortet die API mit „nicht konfiguriert“. Rate-Limiting und einfacher Spam-Schutz sind serverseitig aktiv.
## Projektstruktur
```
kapteins-daagbok/
├── client/ # React-PWA (Frontend)
│ ├── src/
│ │ ├── components/ # UI-Komponenten
│ │ ├── components/ # UI (u. a. CourseDialInput, UserProfilePage, FeedbackModal)
│ │ ├── services/ # Auth, Sync, Krypto, Backup, Push, Analytics, …
│ │ ├── sw.ts # Service Worker (Precache + Web Push)
│ │ └── i18n/ # DE/EN-Übersetzungen
│ └── Dockerfile # Nginx-Produktions-Image
├── server/ # Express-API + Prisma
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign, push
│ ├── src/services/ # z. B. pushNotify (Web Push)
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign, push, feedback, weather
│ ├── src/services/ # z. B. pushNotify, ntfyNotify
│ └── prisma/ # Datenbankschema
├── docs/ # Projektdokumentation
├── scripts/ # Dev- und Deploy-Skripte
@@ -128,8 +151,9 @@ kapteins-daagbok/
- **Node.js** 20+
- **npm**
- **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack)
- Optional: eigener OpenWeatherMap-API-Key in den Einstellungen (sonst serverseitiger Key aus `.env`)
- Optional: eigener OpenWeatherMap-API-Key im **Benutzerprofil** (sonst serverseitiger Key aus `.env`)
- Optional: VAPID-Schlüssel für Web Push (siehe Abschnitt Push-Benachrichtigungen)
- Optional: Ntfy-Topic für Feedback (siehe Abschnitt Feedback)
## Lokale Entwicklung
@@ -166,6 +190,10 @@ SESSION_SECRET= # openssl rand -base64 48 (in Prod Pflicht)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
# Optional — Feedback via Ntfy
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=
NTFY_TOKEN=
```
`./scripts/start-dev.sh` prüft `ORIGIN` und `SESSION_SECRET` beim Start und gibt Hinweise aus.
@@ -189,6 +217,15 @@ cd server && npx prisma db push && cd ..
| Frontend (Vite) | http://localhost:5173 |
| Backend API | http://localhost:5000 |
| Health Check | http://localhost:5000/api/health |
| Public Demo | http://localhost:5173/demo |
### 5. Tests (Frontend)
```bash
cd client && npm test
```
Vitest-Unit-Tests für Utils, i18n und Services (z. B. Kurswinkel, Benutzereinstellungen).
## Docker (produktionsnah)
@@ -198,9 +235,9 @@ Gesamten Stack lokal bauen und starten:
./scripts/start-dev-docker.sh
```
Frontend: http://localhost · API: http://localhost/api/health
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`).
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`). Für Feedback `NTFY_*` setzen.
## Deployment
@@ -212,7 +249,7 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
## Dokumentation
@@ -220,6 +257,7 @@ Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABA
|----------|--------|
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
| [docs/marketing/kapteins-daagbok-beta-flyer.pdf](docs/marketing/kapteins-daagbok-beta-flyer.pdf) | Beta-Flyer (DIN A4) zum Ausdrucken — Quelle: `docs/marketing/beta-flyer.html`, neu erzeugen: `cd client && npm run generate:flyer` |
| [.planning/PROJECT.md](.planning/PROJECT.md) | Produktvision und Anforderungen (GSD) |
+1 -1
View File
@@ -1 +1 @@
0.1.0.39
0.1.0.68
+6 -2
View File
@@ -5,16 +5,20 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="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="robots" content="index, follow" />
<meta name="application-name" content="Kapteins Daagbok" />
<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="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#1e293b" />
<meta name="theme-color" content="#0b0c10" />
<script src="/appearance-bootstrap.js"></script>
<link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Kapteins Daagbok" />
+1 -1
View File
@@ -4,7 +4,7 @@ server {
client_max_body_size 50M;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
+522 -1
View File
@@ -32,12 +32,14 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"happy-dom": "^20.9.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.1"
"vite-plugin-pwa": "^1.0.1",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=20.0.0"
@@ -2896,6 +2898,24 @@
"@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": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -2991,6 +3011,23 @@
"devOptional": true,
"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": {
"version": "8.60.0",
"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"
}
},
"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": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3360,6 +3522,16 @@
"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": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -3541,6 +3713,16 @@
"dev": true,
"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": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -3642,6 +3824,33 @@
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@@ -3848,6 +4057,16 @@
"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": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3979,6 +4198,19 @@
"dev": true,
"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": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -4068,6 +4300,13 @@
"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": {
"version": "1.1.2",
"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"
}
},
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4857,6 +5106,24 @@
"dev": true,
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -5710,6 +5977,13 @@
"dev": true,
"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": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -6007,6 +6281,23 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -6705,6 +6996,13 @@
"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": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -6773,6 +7071,13 @@
"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": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
@@ -6783,6 +7088,13 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -6937,6 +7249,26 @@
"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": {
"version": "1.0.0",
"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"
}
},
"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": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -7035,6 +7381,36 @@
"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": {
"version": "1.0.1",
"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": {
"version": "1.3.0",
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@@ -7486,6 +7958,16 @@
"dev": true,
"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": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
@@ -7610,6 +8092,23 @@
"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": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -7855,6 +8354,28 @@
"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": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+4 -1
View File
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"test": "vitest run",
"preview": "vite preview",
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
"generate:flyer:setup": "playwright install chromium"
@@ -36,12 +37,14 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"happy-dom": "^20.9.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.1"
"vite-plugin-pwa": "^1.0.1",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=20.0.0"
+44
View File
@@ -0,0 +1,44 @@
/**
* Applies saved appearance classes before CSS/JS bundle loads (prevents wrong flash on PWA).
* Logic mirrors client/src/services/appearance.ts + userPreferences.ts.
*/
(function () {
try {
var uid = localStorage.getItem('active_userid')
var theme = 'auto'
var scheme = 'auto'
if (uid) {
theme =
localStorage.getItem('user_pref_theme_' + uid) ||
localStorage.getItem('active_theme') ||
'auto'
scheme =
localStorage.getItem('user_pref_color_scheme_' + uid) ||
localStorage.getItem('active_color_scheme') ||
'auto'
} else {
theme = localStorage.getItem('active_theme') || 'auto'
scheme = localStorage.getItem('active_color_scheme') || 'auto'
}
var resolvedTheme = theme
if (resolvedTheme !== 'ocean' && resolvedTheme !== 'material' && resolvedTheme !== 'cupertino') {
var ua = navigator.userAgent || navigator.vendor || ''
if (/iPad|iPhone|iPod|Macintosh/.test(ua)) resolvedTheme = 'cupertino'
else if (/Android|Linux/.test(ua)) resolvedTheme = 'material'
else resolvedTheme = 'ocean'
}
var resolvedScheme = scheme
if (resolvedScheme !== 'light' && resolvedScheme !== 'dark') {
resolvedScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
var root = document.documentElement
root.classList.add('theme-' + resolvedTheme, 'scheme-' + resolvedScheme)
root.style.colorScheme = resolvedScheme
} catch (_) {
/* ignore storage / matchMedia errors */
}
})()
+1285 -13
View File
File diff suppressed because it is too large Load Diff
+203 -54
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import './App.css'
import { useState, useEffect, useCallback, useRef } from 'react'
import { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx'
import UserProfilePage from './components/UserProfilePage.tsx'
import LogbookDashboard from './components/LogbookDashboard.tsx'
import VesselForm from './components/VesselForm.tsx'
import CrewForm from './components/CrewForm.tsx'
@@ -13,7 +13,14 @@ import SettingsForm from './components/SettingsForm.tsx'
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import AppTourOverlay from './components/AppTourOverlay.tsx'
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
import {
logoutUser,
checkServerSession,
hasUnlockedLocalSession,
persistSessionUserId
} from './services/auth.js'
import AppErrorBoundary from './components/AppErrorBoundary.tsx'
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
import {
applyAppearanceToDocument,
@@ -21,6 +28,7 @@ import {
resolveColorScheme,
subscribeToSystemColorScheme
} from './services/appearance.js'
import { syncAppearancePrefs } from './services/appearancePrefs.js'
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
import DemoViewer from './components/DemoViewer.tsx'
@@ -28,6 +36,7 @@ import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
import AppFooter from './components/AppFooter.tsx'
import LogbookRoleBadge from './components/LogbookRoleBadge.tsx'
import BetaBadge from './components/BetaBadge.tsx'
import { db } from './services/db.js'
import { getLogbookAccess } from './services/logbookAccess.js'
import type { LogbookAccessRole } from './services/logbook.js'
@@ -37,7 +46,7 @@ import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import {
getStoredDemoFirstEntryId,
resolveTourLogbookContext,
seedDemoLogbookIfNeeded
} from './services/demoLogbook.js'
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
@@ -47,7 +56,8 @@ const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
function App() {
const { t, i18n } = useTranslation()
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
const { confirmLeave } = useUnsavedChangesContext()
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
@@ -58,6 +68,13 @@ function App() {
const [online, setOnline] = useState(navigator.onLine)
const [isSyncing, setIsSyncing] = useState(false)
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
const [showUserProfile, setShowUserProfile] = useState(false)
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
id: activeLogbookId,
title: activeLogbookTitle
})
activeLogbookRef.current = { id: activeLogbookId, title: activeLogbookTitle }
// Viewer mode for read-only shared links
const [isViewerMode, setIsViewerMode] = useState(false)
@@ -134,6 +151,13 @@ function App() {
})
}, [])
useEffect(() => {
if (!isAuthenticated) return
const userId = localStorage.getItem('active_userid')
if (!userId) return
void syncAppearancePrefs(userId)
}, [isAuthenticated])
useEffect(() => {
const handleOnline = () => {
setOnline(true)
@@ -203,6 +227,53 @@ 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
// Require full local session (incl. userId) so API calls are not left headless.
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(() => {
let cancelled = false
@@ -211,13 +282,12 @@ function App() {
const session = await checkServerSession()
if (cancelled) return
if (session.authenticated && session.userId) {
localStorage.setItem('active_userid', session.userId)
if (session.authenticated) {
persistSessionUserId(session.userId)
}
const savedUser = localStorage.getItem('active_username')
const key = getActiveMasterKey()
if (session.authenticated && savedUser && key) {
// Cookie alone is insufficient — need in-memory master key, username, and userId for API.
if (session.authenticated && hasUnlockedLocalSession()) {
setIsAuthenticated(true)
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
@@ -226,6 +296,7 @@ function App() {
setActiveLogbookTitle(savedLogbookTitle)
}
}
// authenticated + crypto but no userId: stay on login (enforceUnlockedSession guards active UI)
} catch (err) {
if (!cancelled) {
console.warn('Session restore failed:', err)
@@ -236,7 +307,7 @@ function App() {
return () => {
cancelled = true
}
}, [])
}, [clearAuthenticatedAppState])
useEffect(() => {
syncRouteFromLocation()
@@ -251,28 +322,66 @@ function App() {
setIsAcceptingInvite(false)
}, [])
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: setTourFeedbackOpen
})
}, [registerNavigation])
useEffect(() => {
if (isAuthenticated && activeLogbookId) {
setDemoHighlightEntryId(getStoredDemoFirstEntryId())
}
}, [isAuthenticated, activeLogbookId])
const selectLogbook = (id: string, title: string) => {
const selectLogbook = useCallback((id: string, title: string) => {
setActiveLogbookId(id)
setActiveLogbookTitle(title)
setActiveTab('logs')
setTourSelectedEntryId(null)
localStorage.setItem('active_logbook_id', id)
localStorage.setItem('active_logbook_title', title)
}
}, [])
const ensureTourLogbookOpen = useCallback(async () => {
const ctx = await resolveTourLogbookContext(tourLogbookRef.current?.id)
if (!ctx) return
if (activeLogbookRef.current.id !== ctx.logbookId) {
selectLogbook(ctx.logbookId, ctx.title)
}
if (ctx.firstEntryId) {
setDemoHighlightEntryId(ctx.firstEntryId)
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
}
}, [registerDemoTourContext, selectLogbook])
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: setTourFeedbackOpen,
setProfileOpen: setShowUserProfile,
ensureLogbookForTour: ensureTourLogbookOpen,
setLogbookActive: (active) => {
if (active) {
void ensureTourLogbookOpen()
return
}
const { id, title } = activeLogbookRef.current
if (id && title) {
tourLogbookRef.current = { id, title }
}
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setTourSelectedEntryId(null)
localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title')
}
})
}, [ensureTourLogbookOpen, registerNavigation])
useEffect(() => {
if (!isAuthenticated || !activeLogbookId) return
void (async () => {
const ctx = await resolveTourLogbookContext()
if (!ctx || ctx.logbookId !== activeLogbookId) return
if (ctx.firstEntryId) {
setDemoHighlightEntryId(ctx.firstEntryId)
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
}
})()
}, [isAuthenticated, activeLogbookId, registerDemoTourContext])
const openLogbookById = useCallback(
async (logbookId: string) => {
@@ -288,7 +397,7 @@ function App() {
}
selectLogbook(logbookId, `${logbookId.slice(0, 8)}`)
},
[]
[selectLogbook]
)
const consumePendingPushLogbook = useCallback(() => {
@@ -340,24 +449,45 @@ function App() {
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
if (savedLogbookId && savedLogbookTitle) {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
try {
const books = await fetchLogbooks()
const match = books.find((b) => b.id === savedLogbookId)
if (match) {
setActiveLogbookId(match.id)
setActiveLogbookTitle(match.title)
} else {
localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title')
}
} catch {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
}
}
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()
setIsAuthenticated(false)
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setShowUserProfile(false)
setTourSelectedEntryId(null)
setDemoHighlightEntryId(null)
localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title')
}
const handleBackToDashboard = () => {
const handleBackToDashboard = async () => {
if (!(await confirmLeave())) return
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setTourSelectedEntryId(null)
@@ -420,19 +550,29 @@ function App() {
)
}
const pwaInstallBanner = <PwaInstallPrompt variant="banner" />
const pwaInstallBanner = !isActive ? <PwaInstallPrompt variant="banner" /> : null
const logbookReadOnly =
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
if (!activeLogbookId) {
return (
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
/>
{showUserProfile ? (
<UserProfilePage
onBack={() => setShowUserProfile(false)}
onLogout={handleLogout}
/>
) : (
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
/>
)}
</div>
)
}
@@ -445,13 +585,14 @@ function App() {
{/* Active Logbook Header */}
<header className="app-header">
<div className="app-header-left">
<button className="btn-back" onClick={handleBackToDashboard}>
<button className="btn-back" onClick={handleBackToDashboard} title={t('nav.dashboard')}>
<ChevronLeft size={16} />
{t('nav.dashboard')}
<span className="hide-mobile">{t('nav.dashboard')}</span>
</button>
<div className="app-title-area">
<div className="app-title-row">
<h2>{activeLogbookTitle}</h2>
<BetaBadge />
{activeAccessRole && activeAccessRole !== 'OWNER' && (
<LogbookRoleBadge role={activeAccessRole} />
)}
@@ -503,7 +644,7 @@ function App() {
<aside className="app-sidebar">
<button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')}
onClick={() => void handleTabChange('logs')}
data-tour="nav-logs"
>
<FileText size={18} />
@@ -512,7 +653,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => setActiveTab('vessel')}
onClick={() => void handleTabChange('vessel')}
data-tour="nav-vessel"
>
<Ship size={18} />
@@ -521,7 +662,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
onClick={() => void handleTabChange('crew')}
data-tour="nav-crew"
>
<Users size={18} />
@@ -540,7 +681,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
onClick={() => void handleTabChange('stats')}
data-tour="nav-stats"
>
<BarChart2 size={18} />
@@ -549,7 +690,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
onClick={() => setActiveTab('settings')}
onClick={() => void handleTabChange('settings')}
>
<Settings size={18} />
{t('nav.settings')}
@@ -569,11 +710,15 @@ function App() {
)}
{activeTab === 'vessel' && (
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly} />
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly || !isLogbookOwner} />
)}
{activeTab === 'crew' && (
<CrewForm logbookId={activeLogbookId} readOnly={logbookReadOnly} />
<CrewForm
logbookId={activeLogbookId}
readOnly={logbookReadOnly}
skipperReadOnly={!isLogbookOwner}
/>
)}
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
@@ -601,13 +746,17 @@ function App() {
export default function AppWrapper() {
return (
<DialogProvider>
<AppTourProvider>
<PwaUpdatePrompt />
<App />
<AppTourOverlay />
</AppTourProvider>
<AppFooter />
</DialogProvider>
<AppErrorBoundary>
<DialogProvider>
<UnsavedChangesProvider>
<AppTourProvider>
<PwaUpdatePrompt />
<App />
<AppTourOverlay />
</AppTourProvider>
<AppFooter />
</UnsavedChangesProvider>
</DialogProvider>
</AppErrorBoundary>
)
}
@@ -46,6 +46,7 @@ export default function AccountDangerZone({ className = '' }: AccountDangerZoneP
</div>
<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">
<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>
)
}
}
+88 -17
View File
@@ -4,6 +4,7 @@ import { X, ChevronLeft, ChevronRight } from 'lucide-react'
import {
getTourStepCopy,
getTourTargetSelector,
getTourTargetRetryDelay,
isCenteredTourStep,
useAppTour
} from '../context/AppTourContext.tsx'
@@ -15,12 +16,63 @@ interface SpotlightRect {
height: number
}
const TOOLTIP_EDGE_MARGIN = 16
const TOOLTIP_ESTIMATED_HEIGHT = 240
const TOOLTIP_WIDTH = 420
const TARGET_VIEWPORT_MARGIN = 24
function clampTooltipTop(preferred: number): number {
const maxTop = window.innerHeight - TOOLTIP_EDGE_MARGIN - TOOLTIP_ESTIMATED_HEIGHT
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(preferred, maxTop))
}
function computeTooltipLeft(spotlight: SpotlightRect): number {
const tooltipWidth = Math.min(TOOLTIP_WIDTH, window.innerWidth - TOOLTIP_EDGE_MARGIN * 2)
const ideal = spotlight.left + spotlight.width / 2 - tooltipWidth / 2
const maxLeft = window.innerWidth - TOOLTIP_EDGE_MARGIN - tooltipWidth
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(ideal, maxLeft))
}
function buildCutoutClipPath(rect: SpotlightRect): string {
const right = rect.left + rect.width
const bottom = rect.top + rect.height
return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)`
}
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 clampTooltipTop(below)
}
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
if (above >= TOOLTIP_EDGE_MARGIN) {
return clampTooltipTop(above)
}
return clampTooltipTop(below)
}
function isTargetVisibleInViewport(rect: DOMRect): boolean {
return (
rect.top >= TARGET_VIEWPORT_MARGIN &&
rect.bottom <= window.innerHeight - TARGET_VIEWPORT_MARGIN
)
}
function measureSpotlight(el: Element): SpotlightRect | null {
const rect = el.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
const padding = 8
return {
top: Math.max(8, rect.top - padding),
left: Math.max(8, rect.left - padding),
width: rect.width + padding * 2,
height: rect.height + padding * 2
}
}
export default function AppTourOverlay() {
const { t } = useTranslation()
const {
@@ -29,6 +81,7 @@ export default function AppTourOverlay() {
currentStepId,
currentStepIndex,
totalSteps,
layoutTick,
nextStep,
prevStep,
skipTour
@@ -44,7 +97,10 @@ export default function AppTourOverlay() {
return
}
let cancelled = false
const updateSpotlight = () => {
if (cancelled) return
const selector = getTourTargetSelector(currentStepId)
if (!selector) {
setSpotlight(null)
@@ -55,27 +111,38 @@ export default function AppTourOverlay() {
setSpotlight(null)
return
}
const rect = el.getBoundingClientRect()
const padding = 8
setSpotlight({
top: Math.max(8, rect.top - padding),
left: Math.max(8, rect.left - padding),
width: rect.width + padding * 2,
height: rect.height + padding * 2
})
if (!isTargetVisibleInViewport(rect)) {
el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' })
window.requestAnimationFrame(() => {
if (cancelled) return
const next = measureSpotlight(el)
setSpotlight(next)
})
return
}
setSpotlight(measureSpotlight(el))
}
updateSpotlight()
window.addEventListener('resize', updateSpotlight)
window.addEventListener('scroll', updateSpotlight, true)
const timer = window.setTimeout(updateSpotlight, 120)
const retryDelays =
currentStepId === 'entry_track'
? [400, 700, 1100, 1600]
: [getTourTargetRetryDelay(currentStepId), 120, 280, 480]
const timers = retryDelays.map((delay) => window.setTimeout(updateSpotlight, delay))
return () => {
window.clearTimeout(timer)
cancelled = true
for (const timer of timers) window.clearTimeout(timer)
window.removeEventListener('resize', updateSpotlight)
window.removeEventListener('scroll', updateSpotlight, true)
}
}, [currentStepId, isActive])
}, [currentStepId, isActive, layoutTick])
useEffect(() => {
if (!isActive) return
@@ -111,12 +178,16 @@ export default function AppTourOverlay() {
const tooltipStyle = centered
? undefined
: spotlight
? {
top: Math.min(window.innerHeight - 220, spotlight.top + spotlight.height + 12),
left: Math.min(window.innerWidth - 340, Math.max(16, spotlight.left)),
maxWidth: '420px'
}
: { top: '20%', left: '50%', transform: 'translateX(-50%)', maxWidth: '420px' }
? { top: computeTooltipTop(spotlight), left: computeTooltipLeft(spotlight) }
: { top: '20%' }
const tooltipClassName = [
'app-tour-tooltip',
centered ? 'centered' : '',
!centered && spotlight ? 'app-tour-tooltip--anchored' : ''
]
.filter(Boolean)
.join(' ')
const backdropStyle = spotlight && !centered
? { clipPath: buildCutoutClipPath(spotlight) }
@@ -142,7 +213,7 @@ export default function AppTourOverlay() {
/>
)}
<div className={`app-tour-tooltip${centered ? ' centered' : ''}`} style={tooltipStyle}>
<div className={tooltipClassName} style={tooltipStyle}>
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
<X size={18} />
</button>
+54 -14
View File
@@ -13,6 +13,8 @@ import {
} from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx'
interface AuthOnboardingProps {
onAuthenticated: () => void
@@ -49,6 +51,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [isNewRegistration, setIsNewRegistration] = useState(false)
const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false)
const finishAuth = () => {
if (isNewRegistration) {
@@ -272,6 +275,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</label>
<input
type="password"
name="new-pin"
inputMode="numeric"
pattern="[0-9]*"
maxLength={8}
@@ -281,6 +285,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
onChange={(e) => setPinInput(e.target.value.replace(/\D/g, ''))}
disabled={loading}
required
autoComplete="new-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
@@ -321,6 +326,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="input-group">
<input
type="password"
name="pin"
inputMode="numeric"
pattern="[0-9]*"
maxLength={8}
@@ -330,6 +336,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
onChange={(e) => setPinLoginInput(e.target.value.replace(/\D/g, ''))}
disabled={loading}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
@@ -372,16 +379,37 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
{t('auth.recovery_fallback_warning')}
</p>
<form onSubmit={handleRecoverySubmit} className="auth-form">
<textarea
className="input-textarea"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
disabled={loading}
rows={3}
required
/>
<form onSubmit={handleRecoverySubmit} className="auth-form" autoComplete="on">
{(username.trim() || encryptedPayloads?.username) && (
<input
type="text"
name="username"
autoComplete="username"
value={username.trim() || encryptedPayloads?.username || ''}
readOnly
tabIndex={-1}
aria-hidden="true"
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
/>
)}
<div className="input-group">
<label htmlFor="recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
{t('auth.enter_recovery')}
</label>
<input
id="recovery-key"
name="recovery-key"
type="password"
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
disabled={loading}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
{error && <div className="auth-error">{error}</div>}
@@ -405,10 +433,14 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
// Render 3: Standard Login / Registration options form
return (
<>
<div className="auth-card glass">
<div className="auth-brand">
<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>
</div>
@@ -562,15 +594,23 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</div>
<div className="auth-footer">
<button className="btn-icon-text" onClick={toggleLanguage}>
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
</button>
<a href="#help" className="btn-icon-text link-sec">
<button
type="button"
className="btn-icon-text link-sec"
onClick={() => setShowHelp(true)}
title={t('disclaimer.button_title')}
aria-label={t('disclaimer.button_title')}
>
<HelpCircle size={18} />
{t('auth.help')}
</a>
</button>
</div>
</div>
<DisclaimerModal open={showHelp} onClose={() => setShowHelp(false)} />
</>
)
}
+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 {
logbookId: string
readOnly?: boolean
skipperReadOnly?: boolean
preloadedData?: any[]
}
@@ -34,9 +35,15 @@ interface DecryptedCrew {
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 { showConfirm } = useDialog()
const skipperFormReadOnly = readOnly || skipperReadOnly
// Skipper profile state
const [skipName, setSkipName] = useState('')
@@ -192,7 +199,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
const handleSaveSkipper = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly) return
if (skipperFormReadOnly) return
setSavingSkipper(true)
setError(null)
setSkipperSuccess(false)
@@ -397,10 +404,14 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
{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">
<div className="form-grid">
<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 ? (
<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" />
</div>
)}
{!readOnly && (
{!skipperFormReadOnly && (
<div className="vessel-photo-overlay">
<Camera size={24} />
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
@@ -416,7 +427,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
)}
</div>
{!readOnly && (
{!skipperFormReadOnly && (
<div className="vessel-photo-actions">
<button
type="button"
@@ -473,7 +484,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipName}
onChange={(e) => setSkipName(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
required
/>
</div>
@@ -485,7 +496,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipAddress}
onChange={(e) => setSkipAddress(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -496,7 +507,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipBirthDate}
onChange={(e) => setSkipBirthDate(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -507,7 +518,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipPhone}
onChange={(e) => setSkipPhone(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -518,7 +529,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipNationality}
onChange={(e) => setSkipNationality(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -529,7 +540,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipPassport}
onChange={(e) => setSkipPassport(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -540,7 +551,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipBloodType}
onChange={(e) => setSkipBloodType(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -551,7 +562,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipAllergies}
onChange={(e) => setSkipAllergies(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -562,12 +573,12 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipDiseases}
onChange={(e) => setSkipDiseases(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
</div>
{!readOnly && (
{!skipperFormReadOnly && (
<div className="form-actions">
{skipperSuccess && (
<div className="success-toast">
+3 -1
View File
@@ -31,7 +31,9 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: () => {}
setFeedbackOpen: () => {},
setLogbookActive: () => {},
setProfileOpen: () => {}
})
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
@@ -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>
)
}
+30 -9
View File
@@ -344,15 +344,36 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<h2>{t('auth.enter_recovery')}</h2>
</div>
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
<form onSubmit={handleRecoverySubmit}>
<textarea
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
rows={3}
required
/>
<form onSubmit={handleRecoverySubmit} autoComplete="on">
{(username.trim() || encryptedPayloads?.username) && (
<input
type="text"
name="username"
autoComplete="username"
value={username.trim() || encryptedPayloads?.username || ''}
readOnly
tabIndex={-1}
aria-hidden="true"
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
/>
)}
<div className="input-group">
<label htmlFor="invitation-recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
{t('auth.enter_recovery')}
</label>
<input
id="invitation-recovery-key"
name="recovery-key"
type="password"
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
<div className="auth-actions mt-4">
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
{t('auth.back')}
+15 -7
View File
@@ -241,14 +241,15 @@ export default function LogEntriesList({
decryptedEntries.sort(compareTravelDaysChronological)
const previousEntry = decryptedEntries.at(-1) ?? null
let { freshwater, fuel, departure } = carryOverFromPreviousDay(previousEntry)
let { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, departure })) {
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, greywaterLevel, departure })) {
const confirmed = await showConfirm(
t('logs.carry_over_tanks_confirm', {
departure: departure || '—',
fw: formatTankLiters(freshwater.morning),
fuel: formatTankLiters(fuel.morning)
fuel: formatTankLiters(fuel.morning),
greywater: formatTankLiters(greywaterLevel)
}),
t('logs.carry_over_tanks_title'),
t('logs.carry_over_tanks_yes'),
@@ -257,6 +258,7 @@ export default function LogEntriesList({
if (!confirmed) {
freshwater = emptyTankLevels()
fuel = emptyTankLevels()
greywaterLevel = 0
departure = ''
}
}
@@ -274,6 +276,7 @@ export default function LogEntriesList({
destination: '',
freshwater,
fuel,
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
signSkipper: '',
signCrew: '',
events: []
@@ -365,6 +368,11 @@ export default function LogEntriesList({
)
}
const tourFirstEntryId =
highlightEntryId && entries.some((e) => e.id === highlightEntryId)
? highlightEntryId
: entries[0]?.id ?? null
return (
<div className="form-card">
<div className="section-title-bar mb-6">
@@ -372,7 +380,7 @@ export default function LogEntriesList({
<Calendar size={24} className="form-icon" />
<h2>{t('logs.title')}</h2>
</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')}>
<Download size={16} />
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
@@ -384,9 +392,9 @@ export default function LogEntriesList({
</button>
{!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} />
{t('logs.new_entry')}
<span className="hide-mobile">{t('logs.new_entry')}</span>
</button>
)}
</div>
@@ -402,7 +410,7 @@ export default function LogEntriesList({
<div
key={item.id}
className="logbook-card glass"
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
onClick={() => setSelectedEntryId(item.id)}
>
<div className="card-icon">
File diff suppressed because it is too large Load Diff
+105 -86
View File
@@ -12,6 +12,7 @@ import {
type LogbookBackupPreview
} from '../services/logbookBackup.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
interface LogbookBackupPanelProps {
logbookId: string
@@ -41,7 +42,7 @@ function mapBackupError(code: string, t: (key: string) => string): string {
}
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const { showConfirm } = useDialog()
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -58,6 +59,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const [error, setError] = 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 () => {
setError(null)
setSuccess(null)
@@ -209,40 +220,45 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</h4>
<p className="text-muted backup-section-desc">{t('settings.backup_export_desc')}</p>
<div className="input-group">
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-export-passphrase"
type="password"
className="input-text"
value={exportPassphrase}
onChange={(e) => setExportPassphrase(e.target.value)}
placeholder={t('settings.backup_passphrase_placeholder')}
autoComplete="new-password"
disabled={exporting}
/>
</div>
<div className="input-group">
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
<input
id="backup-export-confirm"
type="password"
className="input-text"
value={exportConfirm}
onChange={(e) => setExportConfirm(e.target.value)}
autoComplete="new-password"
disabled={exporting}
/>
</div>
<button
type="button"
className="btn primary"
onClick={handleExport}
disabled={exporting || !exportPassphrase || !exportConfirm}
>
<Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button>
<form onSubmit={handleExportSubmit} className="backup-export-form">
<div className="input-group">
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-export-passphrase"
name="backup-export-passphrase"
type="password"
className="input-text"
value={exportPassphrase}
onChange={(e) => setExportPassphrase(e.target.value)}
placeholder={t('settings.backup_passphrase_placeholder')}
autoComplete="new-password"
disabled={exporting}
required
/>
</div>
<div className="input-group">
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
<input
id="backup-export-confirm"
name="backup-export-confirm"
type="password"
className="input-text"
value={exportConfirm}
onChange={(e) => setExportConfirm(e.target.value)}
autoComplete="new-password"
disabled={exporting}
required
/>
</div>
<button
type="submit"
className="btn primary"
disabled={exporting || !exportPassphrase || !exportConfirm}
>
<Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button>
</form>
</section>
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
@@ -252,58 +268,61 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</h4>
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
<div className="input-group">
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
<input
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok.json,application/json"
className="input-text"
onChange={handleFileChange}
disabled={importing}
/>
</div>
<form onSubmit={handleImportSubmit} className="backup-import-form">
<div className="input-group">
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
<input
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok.json,application/json"
className="input-text"
onChange={handleFileChange}
disabled={importing}
/>
</div>
{importFile && (
<>
<div className="input-group">
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-import-passphrase"
type="password"
className="input-text"
value={importPassphrase}
onChange={(e) => {
setImportPassphrase(e.target.value)
setImportPreview(null)
}}
autoComplete="current-password"
disabled={importing}
/>
</div>
{importFile && (
<>
<div className="input-group">
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-import-passphrase"
name="backup-import-passphrase"
type="password"
className="input-text"
value={importPassphrase}
onChange={(e) => {
setImportPassphrase(e.target.value)
setImportPreview(null)
}}
autoComplete="current-password"
disabled={importing}
required
/>
</div>
<div className="backup-actions-row">
<button
type="button"
className="btn secondary"
onClick={handlePreviewImport}
disabled={previewing || importing || !importPassphrase}
>
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button>
<button
type="button"
className="btn primary"
onClick={() => handleRestore()}
disabled={importing || !importPassphrase}
>
<Upload size={16} />
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
</button>
</div>
</>
)}
<div className="backup-actions-row">
<button
type="button"
className="btn secondary"
onClick={handlePreviewImport}
disabled={previewing || importing || !importPassphrase}
>
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button>
<button
type="submit"
className="btn primary"
disabled={importing || !importPassphrase}
>
<Upload size={16} />
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
</button>
</div>
</>
)}
</form>
{importPreview && (
<div className="backup-preview glass">
@@ -316,7 +335,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</ul>
<p className="text-muted backup-preview-date">
{t('settings.backup_exported_at', {
date: new Date(importPreview.exportedAt).toLocaleString()
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
})}
</p>
</div>
+305 -43
View File
@@ -1,35 +1,81 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '../services/db.js'
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
onLogout: () => void
onOpenProfile: () => void
}
export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookDashboardProps) {
function logbookMatchesFilter(lb: DecryptedLogbook, query: string, locale: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
if (lb.title.toLowerCase().includes(q)) return true
const updated = new Date(lb.updatedAt)
const year = updated.getFullYear().toString()
if (year.includes(q)) return true
const dateLabel = updated.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
}).toLowerCase()
if (dateLabel.includes(q)) return true
return false
}
type LogbookSortKey = 'name' | 'date'
type LogbookSortDirection = 'asc' | 'desc'
function sortLogbooks(
items: DecryptedLogbook[],
sortBy: LogbookSortKey,
direction: LogbookSortDirection,
locale: string
): DecryptedLogbook[] {
const sorted = [...items]
sorted.sort((a, b) => {
const cmp =
sortBy === 'name'
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
return direction === 'asc' ? cmp : -cmp
})
return sorted
}
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
const { t, i18n } = useTranslation()
const { showConfirm } = useDialog()
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
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 [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filterQuery, setFilterQuery] = useState('')
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null)
const [online, setOnline] = useState(navigator.onLine)
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
// Reactive sync queue count
const pendingCount = useLiveQuery(() => db.syncQueue.count()) || 0
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
// Listen to connectivity changes
useEffect(() => {
@@ -98,6 +144,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 = () => {
void logoutUser()
onLogout()
@@ -111,7 +200,29 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
const renderLogbookCard = (lb: DecryptedLogbook) => (
const filterActive = filterQuery.trim().length > 0
const filteredOwnedLogbooks = useMemo(
() => ownedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
[ownedLogbooks, filterQuery, i18n.language]
)
const filteredSharedLogbooks = useMemo(
() => sharedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
[sharedLogbooks, filterQuery, i18n.language]
)
const sortedOwnedLogbooks = useMemo(
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
[filteredOwnedLogbooks, sortBy, sortDirection, i18n.language]
)
const sortedSharedLogbooks = useMemo(
() => sortLogbooks(filteredSharedLogbooks, sortBy, sortDirection, i18n.language),
[filteredSharedLogbooks, sortBy, sortDirection, i18n.language]
)
const filteredLogbookCount = sortedOwnedLogbooks.length + sortedSharedLogbooks.length
const renderLogbookCard = (lb: DecryptedLogbook) => {
const isEditingTitle = editingLogbookId === lb.id
return (
<div
key={lb.id}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
@@ -123,7 +234,36 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<div className="card-info">
<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} />
</div>
<div className="card-meta">
@@ -143,16 +283,22 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div>
</div>
<button
className="btn-delete"
onClick={(e) => handleDelete(lb.id, e)}
title={t('dashboard.delete_btn')}
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
>
<Trash2 size={18} />
</button>
{!lb.isShared && (
<div className="logbook-card-actions">
<button
type="button"
className="btn-delete"
onClick={(e) => handleDelete(lb.id, e)}
title={t('dashboard.delete_btn')}
aria-label={t('dashboard.delete_btn')}
>
<Trash2 size={18} />
</button>
</div>
)}
</div>
)
)
}
const renderLogbookSection = (
title: string,
@@ -177,18 +323,37 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<div className="header-brand">
<Ship className="header-logo" size={32} />
<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>
</div>
</div>
<div className="header-actions">
{/* Connection Indicator */}
<div className={`conn-status ${online ? (pendingCount > 0 ? 'unsynced' : 'online') : 'offline'}`} title={online ? (pendingCount > 0 ? 'Pending Sync' : 'Synced') : 'Offline'}>
<div
className={connStatusClassName(online)}
title={
online
? showSpinner
? 'Syncing'
: pendingCount > 0
? 'Pending Sync'
: 'Synced'
: 'Offline'
}
>
{online ? (
pendingCount > 0 ? (
showSpinner ? (
<>
<RefreshCw size={18} className="spin" />
<span>{t('sync.status_syncing')}</span>
</>
) : showPendingWarning ? (
<>
<RefreshCw size={18} />
<span>{t('sync.status_unsynced')} ({pendingCount})</span>
</>
) : (
@@ -206,14 +371,17 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div>
{/* Skipper profile */}
<div
className="skipper-badge"
title={t('dashboard.logged_in_as', { name: username })}
aria-label={t('dashboard.logged_in_as', { name: username })}
<button
type="button"
className="btn-icon skipper-badge"
onClick={onOpenProfile}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
data-tour="nav-profile"
>
<User size={16} aria-hidden="true" />
<User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</div>
</button>
{/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
@@ -271,24 +439,118 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
) : logbooks.length === 0 ? (
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
) : (
<div className="logbook-sections">
{ownedLogbooks.length > 0 && renderLogbookSection(
sharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
ownedLogbooks
<>
<div className="dashboard-list-controls">
<div className="dashboard-filter-bar">
<label className="dashboard-filter-label" htmlFor="logbook-list-filter">
{t('dashboard.filter_label')}
</label>
<div className="dashboard-filter-input-wrap">
<Search size={18} className="dashboard-filter-icon" aria-hidden="true" />
<input
ref={filterInputRef}
id="logbook-list-filter"
type="search"
className="input-text dashboard-filter-input"
placeholder={t('dashboard.filter_placeholder')}
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
autoComplete="off"
spellCheck={false}
aria-describedby={filterActive ? 'logbook-filter-status' : undefined}
/>
{filterActive && (
<button
type="button"
className="dashboard-filter-clear"
onClick={() => {
setFilterQuery('')
filterInputRef.current?.focus()
}}
title={t('dashboard.filter_clear')}
aria-label={t('dashboard.filter_clear')}
>
<X size={16} aria-hidden="true" />
</button>
)}
</div>
{filterActive && (
<p id="logbook-filter-status" className="dashboard-filter-meta" role="status">
{t('dashboard.filter_results', { count: filteredLogbookCount })}
</p>
)}
</div>
<div className="dashboard-sort-bar">
<span className="dashboard-sort-label">{t('dashboard.sort_label')}</span>
<div className="dashboard-sort-row">
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_by_label')}>
<button
type="button"
className={`dashboard-sort-btn${sortBy === 'name' ? ' is-active' : ''}`}
onClick={() => setSortBy('name')}
aria-pressed={sortBy === 'name'}
aria-label={t('dashboard.sort_by_name')}
title={t('dashboard.sort_by_name')}
>
<CaseSensitive size={16} aria-hidden="true" />
</button>
<button
type="button"
className={`dashboard-sort-btn${sortBy === 'date' ? ' is-active' : ''}`}
onClick={() => setSortBy('date')}
aria-pressed={sortBy === 'date'}
aria-label={t('dashboard.sort_by_date')}
title={t('dashboard.sort_by_date')}
>
<CalendarDays size={16} aria-hidden="true" />
</button>
</div>
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_dir_label')}>
<button
type="button"
className={`dashboard-sort-btn${sortDirection === 'asc' ? ' is-active' : ''}`}
onClick={() => setSortDirection('asc')}
aria-pressed={sortDirection === 'asc'}
aria-label={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
title={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
>
<ArrowUp size={16} aria-hidden="true" />
</button>
<button
type="button"
className={`dashboard-sort-btn${sortDirection === 'desc' ? ' is-active' : ''}`}
onClick={() => setSortDirection('desc')}
aria-pressed={sortDirection === 'desc'}
aria-label={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
title={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
>
<ArrowDown size={16} aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
{filterActive && filteredLogbookCount === 0 ? (
<div className="dashboard-status-msg glass">{t('dashboard.filter_no_results')}</div>
) : (
<div className="logbook-sections">
{sortedOwnedLogbooks.length > 0 && renderLogbookSection(
sortedSharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
sortedOwnedLogbooks
)}
{sortedSharedLogbooks.length > 0 && renderLogbookSection(
t('dashboard.section_shared'),
sortedSharedLogbooks,
t('dashboard.section_shared_hint')
)}
</div>
)}
{sharedLogbooks.length > 0 && renderLogbookSection(
t('dashboard.section_shared'),
sharedLogbooks,
t('dashboard.section_shared_hint')
)}
</div>
</>
)}
</section>
</main>
<section className="dashboard-account-section" aria-label={t('settings.danger_zone_title')}>
<AccountDangerZone />
</section>
</div>
)
}
+2 -3
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Fingerprint, Loader2, AlertTriangle } from 'lucide-react'
import type { PasskeySignature } from '../types/signatures.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
interface PasskeySignButtonProps {
label: string
@@ -42,9 +43,7 @@ export default function PasskeySignButton({
}
}
const formattedDate = signature
? new Date(signature.signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
: ''
const formattedDate = signature ? formatAppDateTime(signature.signedAt, i18n.language) : ''
return (
<div className="passkey-sign-block">
-1
View File
@@ -233,7 +233,6 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
<input
type="file"
accept="image/*"
capture="environment"
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
@@ -56,7 +56,7 @@ export default function PushNotificationSettings() {
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('settings.push_error')
const message = err instanceof Error ? err.message : t('profile.push_error')
showAlert(message)
void loadPrefs()
} finally {
@@ -69,10 +69,10 @@ export default function PushNotificationSettings() {
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<BellOff size={20} style={{ color: '#94a3b8' }} />
<h3 style={{ margin: 0, color: '#94a3b8', fontSize: '16px' }}>{t('settings.push_title')}</h3>
<h3 style={{ margin: 0, color: '#94a3b8', fontSize: '16px' }}>{t('profile.push_title')}</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: 0 }}>
{t('settings.push_unsupported')}
{t('profile.push_unsupported')}
</p>
</div>
)
@@ -83,23 +83,23 @@ export default function PushNotificationSettings() {
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Bell size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.push_title')}
{t('profile.push_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.push_desc')}
{t('profile.push_desc')}
</p>
{iosNeedsInstall && (
<p className="text-muted" style={{ fontSize: '13px', margin: '0 0 12px 0' }}>
{t('settings.push_ios_install_hint')}
{t('profile.push_ios_install_hint')}
</p>
)}
{permission === 'denied' && (
<p style={{ fontSize: '13px', color: '#f87171', margin: '0 0 12px 0' }}>
{t('settings.push_denied_hint')}
{t('profile.push_denied_hint')}
</p>
)}
@@ -122,12 +122,12 @@ export default function PushNotificationSettings() {
disabled={loading || toggling || iosNeedsInstall}
style={{ width: '18px', height: '18px', cursor: 'inherit' }}
/>
<span>{t('settings.push_enable')}</span>
<span>{t('profile.push_enable')}</span>
</label>
{enabled && permission === 'granted' && (
<p className="text-muted" style={{ fontSize: '12px', margin: '12px 0 0 0' }}>
{t('settings.push_active')}
{t('profile.push_active')}
</p>
)}
</div>
+74 -179
View File
@@ -1,17 +1,17 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx'
import { useDialog } from './ModalDialog.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import ThemedSelect from './ThemedSelect.tsx'
import { useAppTour } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiFetch } from '../services/api.js'
import {
enableCollaboratorChangePush,
isCollaboratorPushActive,
isPushSupported
} from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
interface SettingsFormProps {
logbookId?: string | null
@@ -26,7 +26,6 @@ interface Collaborator {
createdAt: string
}
// Convert ArrayBuffer to Hex String for URL fragment
const bufferToHex = (buffer: ArrayBuffer): string => {
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
@@ -36,14 +35,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog()
const { restartTour } = useAppTour()
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
const [colorScheme, setColorScheme] = useState(localStorage.getItem('active_color_scheme') || 'auto')
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
// Collaboration States
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
const [isOwner, setIsOwner] = useState(true)
const [inviteLink, setInviteLink] = useState('')
@@ -52,7 +44,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const [collabError, setCollabError] = useState<string | null>(null)
const [loadingCollabs, setLoadingCollabs] = useState(false)
// Public Share Link States
const [shareEnabled, setShareEnabled] = useState(false)
const [shareLink, setShareLink] = useState('')
const [shareCopied, setShareCopied] = useState(false)
@@ -121,9 +112,9 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
} else {
throw new Error('Failed to toggle public share link.')
}
} catch (err: any) {
} catch (err: unknown) {
console.error('Toggle share link failed:', err)
showAlert(err.message || 'Failed to update public share link.')
showAlert(err instanceof Error ? err.message : 'Failed to update public share link.')
} finally {
setLoadingShareLink(false)
}
@@ -137,7 +128,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
}
}
const loadCollaborators = async () => {
setLoadingCollabs(true)
setCollabError(null)
@@ -167,6 +157,43 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
}
}
const promptPushAfterInviteCreated = async () => {
if (!isPushSupported()) return
if (await isCollaboratorPushActive()) return
const iosNeedsInstall = isIosDevice() && !isRunningStandalone()
if (iosNeedsInstall) {
await showAlert(
t('settings.invite_push_prompt_ios_message'),
t('settings.invite_push_prompt_title'),
t('settings.invite_push_prompt_later')
)
return
}
const enable = await showConfirm(
t('settings.invite_push_prompt_message'),
t('settings.invite_push_prompt_title'),
t('settings.invite_push_prompt_enable'),
t('settings.invite_push_prompt_later')
)
if (!enable) return
try {
await enableCollaboratorChangePush()
await showAlert(
t('settings.invite_push_prompt_success'),
t('settings.invite_push_prompt_title')
)
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
} catch (err: unknown) {
console.error('Failed to enable push after invite:', err)
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
}
}
const handleGenerateInvite = async () => {
if (!logbookId) return
setGeneratingInvite(true)
@@ -174,10 +201,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
if (!localStorage.getItem('active_userid')) return
try {
// 1. Ensure logbook has an E2E key (upgrades legacy logbooks if needed)
const logbookKey = await ensureLogbookKey(logbookId)
// 2. Create invite token on server
const res = await apiFetch('/api/collaboration/invite', {
method: 'POST',
body: JSON.stringify({ logbookId, role: 'WRITE' })
@@ -188,16 +213,15 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
}
const invite = await res.json()
// 3. Format link containing token (URL params) and key (URL hash anchor)
const hexKey = bufferToHex(logbookKey)
const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}`
setInviteLink(link)
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
} catch (err: any) {
await promptPushAfterInviteCreated()
} catch (err: unknown) {
console.error('Failed to generate invite:', err)
showAlert(err.message || 'Failed to generate invite link.')
showAlert(err instanceof Error ? err.message : 'Failed to generate invite link.')
} finally {
setGeneratingInvite(false)
}
@@ -226,40 +250,26 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
} else {
throw new Error('Failed to revoke collaborator access.')
}
} catch (err: any) {
} catch (err: unknown) {
console.error('Revocation failed:', err)
showAlert(err.message || 'Failed to revoke access.')
showAlert(err instanceof Error ? err.message : 'Failed to revoke access.')
}
}
}
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
localStorage.setItem('active_theme', nextTheme)
localStorage.setItem('active_color_scheme', nextColorScheme)
notifyAppearanceChanged()
}
const handleThemeChange = (nextTheme: string) => {
setTheme(nextTheme)
persistAppearance(nextTheme, colorScheme)
}
const handleColorSchemeChange = (nextColorScheme: string) => {
setColorScheme(nextColorScheme)
persistAppearance(theme, nextColorScheme)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setSuccess(false)
localStorage.setItem('owm_api_key', apiKey.trim())
persistAppearance(theme, colorScheme)
setSaving(false)
setSuccess(true)
setTimeout(() => setSuccess(false), 3000)
if (!logbookId) {
return (
<div className="form-card">
<div className="form-header">
<SettingsIcon size={24} className="form-icon" />
<div>
<h2>{t('settings.title')}</h2>
<p className="form-subtitle">{t('settings.subtitle')}</p>
</div>
</div>
<p className="text-muted mt-4">{t('settings.select_logbook_hint')}</p>
</div>
)
}
return (
@@ -268,126 +278,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
<SettingsIcon size={24} className="form-icon" />
<div>
<h2>{t('settings.title')}</h2>
<p className="form-subtitle" style={{ margin: '4px 0 0 0', fontSize: '13px', color: '#94a3b8' }}>
{t('settings.subtitle')}
</p>
<p className="form-subtitle">{t('settings.subtitle')}</p>
</div>
</div>
<form onSubmit={handleSubmit} className="vessel-form mt-6">
<PwaInstallPrompt variant="inline" />
<PushNotificationSettings />
{/* Weather Integration card */}
<div className="member-editor-card glass">
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
{t('settings.owm_title')}
</h3>
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.key_help')}
</p>
<div className="input-group">
<label htmlFor="owm-api-key" style={{ display: 'block', fontSize: '13.5px', color: '#94a3b8', marginBottom: '6px', fontWeight: 500 }}>
{t('settings.owm_key')}
</label>
<input
id="owm-api-key"
type="password"
className="input-text"
placeholder="e.g. 8b6a7f...d8"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={saving}
/>
</div>
</div>
{/* Theme customization card */}
<div className="member-editor-card glass mt-4">
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
{t('settings.theme_title')}
</h3>
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.theme_label')}
</p>
<div className="input-group">
<ThemedSelect
id="app-theme"
value={theme}
disabled={saving}
onChange={handleThemeChange}
options={[
{ value: 'auto', label: t('settings.theme_auto') },
{ value: 'ocean', label: t('settings.theme_ocean') },
{ value: 'material', label: t('settings.theme_material') },
{ value: 'cupertino', label: t('settings.theme_cupertino') }
]}
/>
</div>
</div>
<div className="member-editor-card glass mt-4">
<h3 style={{ marginTop: 0, marginBottom: '12px', color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.color_scheme_title')}
</h3>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.color_scheme_label')}
</p>
<div className="input-group">
<ThemedSelect
id="app-color-scheme"
value={colorScheme}
disabled={saving}
onChange={handleColorSchemeChange}
options={[
{ value: 'auto', label: t('settings.color_scheme_auto') },
{ value: 'light', label: t('settings.color_scheme_light') },
{ value: 'dark', label: t('settings.color_scheme_dark') }
]}
/>
</div>
</div>
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Compass size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.tour_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.tour_desc')}
</p>
<button
type="button"
className="btn secondary"
onClick={() => restartTour()}
>
{t('settings.tour_restart')}
</button>
</div>
<div className="form-actions mt-4 mb-6">
{success && (
<div className="success-toast">
<Check size={16} />
<span>{t('settings.saved')}</span>
</div>
)}
<button type="submit" className="btn primary" disabled={saving}>
<Save size={18} />
{saving ? t('settings.saving') : t('settings.save')}
</button>
</div>
</form>
{/* Public Share Link Card (Only visible to Logbook Owner) */}
{logbookId && isOwner && (
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
<div className="member-editor-card glass mt-6">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<LinkIcon size={20} style={{ color: '#fbbf24' }} />
<h3 style={{ margin: 0, color: '#fbbf24', fontSize: '16px' }}>
@@ -399,6 +295,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
{t('settings.share_desc')}
</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' }}>
<label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}>
<input
@@ -414,7 +314,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{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
type="text"
readOnly
@@ -436,12 +336,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
)}
{/* Backup & Restore (owner only) */}
{logbookId && isOwner && (
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
)}
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
{logbookId && isOwner && (
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
@@ -455,7 +353,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
{t('logs.invite_link_desc')}
</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
type="button"
className="btn primary"
@@ -469,7 +367,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{inviteLink && (
<div className="input-group mb-6" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<div className="input-group mb-6 copy-link-row">
<input
type="text"
readOnly
@@ -489,7 +387,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
)}
{/* Collaborator List */}
<h4 style={{ color: '#fbbf24', fontSize: '14px', marginBottom: '12px' }}>
{t('logs.collaborators_list')}
</h4>
@@ -535,8 +432,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
)}
</div>
)}
{/* Danger Zone / Account Deletion */}
<AccountDangerZone className="mt-6" />
</div>
)
}
+26 -9
View File
@@ -4,7 +4,8 @@ import { Check } from 'lucide-react'
import SignaturePad from './SignaturePad.tsx'
import PasskeySignButton from './PasskeySignButton.tsx'
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'
@@ -13,7 +14,7 @@ interface SignatureSectionProps {
disabled?: boolean
isOnline: boolean
canSignSkipper: boolean
hasWriteCollaborators: boolean
canSignCrew: boolean
signSkipper: SignatureValue | ''
signCrew: SignatureValue | ''
skipperSignatureValid: boolean
@@ -25,14 +26,28 @@ interface SignatureSectionProps {
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 {
if (!value || isPasskeySignature(value)) return ''
return value
return getSignaturePayload(value)
}
function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode {
if (isPasskeySignature(value)) return 'passkey'
if (value) return 'classic'
if (getSignaturePayload(value)) return 'classic'
return passkeyAvailable ? 'passkey' : 'classic'
}
@@ -108,6 +123,7 @@ function RoleSignatureBlock({
}
return (
<div className="signature-role-block">
<SignerAttributionBadge value={value} />
<SignaturePad
id={padId}
label={roleLabel}
@@ -162,6 +178,7 @@ function RoleSignatureBlock({
{showClassicPanel && (
<>
<SignerAttributionBadge value={value} />
<SignaturePad
id={padId}
label={roleLabel}
@@ -189,7 +206,7 @@ export default function SignatureSection({
disabled = false,
isOnline,
canSignSkipper,
hasWriteCollaborators,
canSignCrew,
signSkipper,
signCrew,
skipperSignatureValid,
@@ -203,7 +220,7 @@ export default function SignatureSection({
const { t } = useTranslation()
const showSkipperPasskey = canSignSkipper && isOnline
const showCrewPasskey = hasWriteCollaborators && isOnline
const showCrewPasskey = canSignCrew && isOnline
const hasSignature = !!(signSkipper || signCrew)
return (
@@ -228,7 +245,7 @@ export default function SignatureSection({
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
signatureValid={skipperSignatureValid}
showPasskey={showSkipperPasskey}
readOnly={readOnly}
readOnly={readOnly || !canSignSkipper}
disabled={disabled}
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined}
@@ -245,7 +262,7 @@ export default function SignatureSection({
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
signatureValid={crewSignatureValid}
showPasskey={showCrewPasskey}
readOnly={readOnly}
readOnly={readOnly || !canSignCrew}
disabled={disabled}
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
onChange={onSignCrewChange}
+83 -1
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge } from 'lucide-react'
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge, Timer } from 'lucide-react'
import MultiTrackMap from './MultiTrackMap.tsx'
import {
formatLiters,
formatHours,
formatNm,
loadAccountStats,
loadLogbookStats,
@@ -12,6 +13,7 @@ import {
type TravelDayStats
} from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
interface StatsDashboardProps {
logbookId: string
@@ -78,12 +80,26 @@ function TotalsGrid({ totals }: { totals: StatsTotals }) {
value={formatNm(totals.motorDistanceNm)}
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
icon={<Fuel size={20} />}
label={t('stats.fuel_total')}
value={formatLiters(totals.totalFuelL)}
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
icon={<Droplets size={20} />}
label={t('stats.water_total')}
@@ -247,6 +263,36 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
/>
</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">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
<p className="stats-section-sub">
@@ -256,6 +302,9 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
{totals.fuelPerNmL != null && (
<> · {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>
<ConsumptionChart days={travelDays} />
</div>
@@ -367,6 +416,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<th>{t('stats.travel_days')}</th>
<th>{t('stats.total_distance')}</th>
<th>{t('stats.fuel_total')}</th>
<th>{t('stats.motor_hours_total')}</th>
<th>{t('stats.water_total')}</th>
</tr>
</thead>
@@ -377,6 +427,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<td>{lb.totals.travelDayCount}</td>
<td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</td>
<td>{formatLiters(lb.totals.totalFuelL)} {t('stats.unit_l')}</td>
<td>{formatHours(lb.totals.totalMotorHours)} {t('stats.unit_h')}</td>
<td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td>
</tr>
))}
@@ -397,8 +448,39 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
/>
</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">
<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} />
</div>
+103
View File
@@ -0,0 +1,103 @@
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { clampTankLiters } from '../utils/tankCapacity.js'
interface TankLiterInputProps {
id?: string
label: string
value: string
onChange: (value: string) => void
maxLiters?: number
disabled?: boolean
titleTooltip?: string
}
function parseInputLiters(value: string): number {
const trimmed = value.trim().replace(',', '.')
if (!trimmed) return 0
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : 0
}
export default function TankLiterInput({
id,
label,
value,
onChange,
maxLiters,
disabled = false,
titleTooltip
}: TankLiterInputProps) {
const { t } = useTranslation()
const useSlider = maxLiters != null && maxLiters > 0
const emitValue = useCallback(
(liters: number) => {
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
const str =
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
onChange(str)
},
[onChange, maxLiters, useSlider]
)
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}
const handleNumberBlur = () => {
if (!useSlider) return
emitValue(parseInputLiters(value))
}
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
emitValue(Number(e.target.value))
}
const numericValue = parseInputLiters(value)
const sliderValue = useSlider ? clampTankLiters(numericValue, maxLiters) : 0
return (
<div className="input-group tank-liter-input">
<label htmlFor={id} title={titleTooltip}>{label}</label>
{useSlider && (
<>
<input
type="range"
className="tank-liter-slider"
min={0}
max={maxLiters}
step={1}
value={sliderValue}
onChange={handleSliderChange}
disabled={disabled}
title={titleTooltip}
aria-valuemin={0}
aria-valuemax={maxLiters}
aria-valuenow={sliderValue}
aria-label={label}
/>
<div className="tank-liter-slider-hint" aria-hidden="true">
{t('logs.tank_slider_of_max', {
current: sliderValue,
max: maxLiters
})}
</div>
</>
)}
<input
id={id}
type="number"
className="input-text"
value={value}
onChange={handleNumberChange}
onBlur={handleNumberBlur}
disabled={disabled}
min={0}
max={useSlider ? maxLiters : undefined}
step="any"
title={titleTooltip}
/>
</div>
)
}
+798
View File
@@ -0,0 +1,798 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
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 UserProfilePreferences from './UserProfilePreferences.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 {
pendingCount: pendingSyncCount,
showSpinner,
showPendingWarning,
connStatusClassName
} = useSyncIndicator()
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 ? (
<>
<div data-tour="profile-preferences">
<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>
<UserProfilePreferences userId={profile.userId} />
</div>
<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 ${connStatusClassName(online)}`}>
{online ? (
showSpinner ? (
<>
<RefreshCw size={16} className="spin" aria-hidden="true" />
<span>{t('sync.status_syncing')}</span>
</>
) : showPendingWarning ? (
<>
<RefreshCw size={16} 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 profile-stats-section">
<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 profile-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,159 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Compass, Palette, Save, Check, Cloud } from 'lucide-react'
import ThemedSelect from './ThemedSelect.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import { saveAppearancePrefsToServer } from '../services/appearancePrefs.js'
import { useAppTour } from '../context/AppTourContext.tsx'
import {
getColorSchemePreference,
getOwmApiKey,
getThemePreference,
setColorSchemePreference,
setOwmApiKey,
setThemePreference
} from '../services/userPreferences.js'
interface UserProfilePreferencesProps {
userId: string
}
export default function UserProfilePreferences({ userId }: UserProfilePreferencesProps) {
const { t } = useTranslation()
const { restartTour } = useAppTour()
const [apiKey, setApiKey] = useState(() => getOwmApiKey(userId))
const [theme, setTheme] = useState(() => getThemePreference(userId))
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
const [savingOwm, setSavingOwm] = useState(false)
const [owmSaved, setOwmSaved] = useState(false)
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
setThemePreference(userId, nextTheme)
setColorSchemePreference(userId, nextColorScheme)
notifyAppearanceChanged()
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
console.warn('Failed to save appearance prefs to server:', err)
})
}
const handleThemeChange = (nextTheme: string) => {
setTheme(nextTheme)
persistAppearance(nextTheme, colorScheme)
}
const handleColorSchemeChange = (nextColorScheme: string) => {
setColorScheme(nextColorScheme)
persistAppearance(theme, nextColorScheme)
}
const handleSaveOwm = (e: React.FormEvent) => {
e.preventDefault()
setSavingOwm(true)
setOwmSaved(false)
setOwmApiKey(userId, apiKey)
setSavingOwm(false)
setOwmSaved(true)
window.setTimeout(() => setOwmSaved(false), 3000)
}
return (
<>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Palette size={20} />
<h3>{t('profile.appearance_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.appearance_desc')}</p>
<div className="input-group">
<label htmlFor="profile-app-theme" className="profile-field-label">
{t('profile.theme_label')}
</label>
<ThemedSelect
id="profile-app-theme"
value={theme}
onChange={handleThemeChange}
options={[
{ value: 'auto', label: t('profile.theme_auto') },
{ value: 'ocean', label: t('profile.theme_ocean') },
{ value: 'material', label: t('profile.theme_material') },
{ value: 'cupertino', label: t('profile.theme_cupertino') }
]}
/>
</div>
<div className="input-group mt-4">
<label htmlFor="profile-color-scheme" className="profile-field-label">
{t('profile.color_scheme_label')}
</label>
<ThemedSelect
id="profile-color-scheme"
value={colorScheme}
onChange={handleColorSchemeChange}
options={[
{ value: 'auto', label: t('profile.color_scheme_auto') },
{ value: 'light', label: t('profile.color_scheme_light') },
{ value: 'dark', label: t('profile.color_scheme_dark') }
]}
/>
</div>
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Compass size={20} />
<h3>{t('profile.tour_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.tour_desc')}</p>
<div className="form-actions">
<button type="button" className="btn secondary" onClick={() => restartTour()}>
{t('profile.tour_restart')}
</button>
</div>
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Cloud size={20} />
<h3>{t('profile.integrations_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.owm_help')}</p>
<form onSubmit={handleSaveOwm}>
<div className="input-group">
<label htmlFor="profile-owm-api-key" className="profile-field-label">
{t('profile.owm_key')}
</label>
<input
id="profile-owm-api-key"
name="owm-api-key"
type="password"
className="input-text"
placeholder="e.g. 8b6a7f...d8"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={savingOwm}
autoComplete="off"
/>
</div>
<div className="form-actions mt-4">
{owmSaved && (
<div className="success-toast">
<Check size={16} />
<span>{t('profile.prefs_saved')}</span>
</div>
)}
<button type="submit" className="btn primary" disabled={savingOwm}>
<Save size={18} />
{savingOwm ? t('profile.prefs_saving') : t('profile.prefs_save')}
</button>
</div>
</form>
</section>
<PushNotificationSettings />
<PwaInstallPrompt variant="inline" />
</>
)
}
+65 -2
View File
@@ -7,6 +7,7 @@ import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
import { parseOptionalTankLiters, tankCapacityInputFromStored } from '../utils/tankCapacity.js'
interface VesselFormProps {
logbookId: string
@@ -47,6 +48,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
const [mmsi, setMmsi] = useState('')
const [sails, setSails] = useState<string[]>([])
const [newSailName, setNewSailName] = useState('')
const [freshwaterCapacityL, setFreshwaterCapacityL] = useState('')
const [fuelCapacityL, setFuelCapacityL] = useState('')
const [greywaterCapacityL, setGreywaterCapacityL] = useState('')
const fileInputRef = React.useRef<HTMLInputElement>(null)
const [photo, setPhoto] = useState<string | null>(null)
@@ -78,6 +82,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
setMmsi(preloadedData.mmsi || '')
setSails(preloadedData.sails || [])
setPhoto(preloadedData.photo || null)
setFreshwaterCapacityL(tankCapacityInputFromStored(preloadedData.freshwaterCapacityL))
setFuelCapacityL(tankCapacityInputFromStored(preloadedData.fuelCapacityL))
setGreywaterCapacityL(tankCapacityInputFromStored(preloadedData.greywaterCapacityL))
return
}
@@ -103,6 +110,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
setMmsi(decrypted.mmsi || '')
setSails(decrypted.sails || [])
setPhoto(decrypted.photo || null)
setFreshwaterCapacityL(tankCapacityInputFromStored(decrypted.freshwaterCapacityL))
setFuelCapacityL(tankCapacityInputFromStored(decrypted.fuelCapacityL))
setGreywaterCapacityL(tankCapacityInputFromStored(decrypted.greywaterCapacityL))
}
}
} catch (err: any) {
@@ -201,12 +211,19 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
let parsedLengthM: number | undefined
let parsedDraftM: number | undefined
let parsedAirDraftM: number | undefined
let parsedFreshwaterCapacityL: number | undefined
let parsedFuelCapacityL: number | undefined
let parsedGreywaterCapacityL: number | undefined
try {
parsedLengthM = parseOptionalMetricMeters(lengthM)
parsedDraftM = parseOptionalMetricMeters(draftM)
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
} catch {
setError(t('vessel.invalid_metric'))
parsedFreshwaterCapacityL = parseOptionalTankLiters(freshwaterCapacityL)
parsedFuelCapacityL = parseOptionalTankLiters(fuelCapacityL)
parsedGreywaterCapacityL = parseOptionalTankLiters(greywaterCapacityL)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : ''
setError(msg === 'invalid_tank_liters' ? t('vessel.invalid_tank_liters') : t('vessel.invalid_metric'))
setSaving(false)
return
}
@@ -217,6 +234,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
lengthM: parsedLengthM,
draftM: parsedDraftM,
airDraftM: parsedAirDraftM,
freshwaterCapacityL: parsedFreshwaterCapacityL,
fuelCapacityL: parsedFuelCapacityL,
greywaterCapacityL: parsedGreywaterCapacityL,
homePort: homePort.trim(),
charterCompany: charterCompany.trim(),
owner: owner.trim(),
@@ -480,6 +500,49 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
/>
</div>
<div className="vessel-tanks-section">
<h3>{t('vessel.tanks_section')}</h3>
<p className="vessel-tanks-help">{t('vessel.tanks_help')}</p>
<div className="vessel-tanks-grid">
<div className="input-group">
<label>{t('vessel.freshwater_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={freshwaterCapacityL}
onChange={(e) => setFreshwaterCapacityL(e.target.value)}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
<div className="input-group">
<label>{t('vessel.fuel_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={fuelCapacityL}
onChange={(e) => setFuelCapacityL(e.target.value)}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
<div className="input-group">
<label>{t('vessel.greywater_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={greywaterCapacityL}
onChange={(e) => setGreywaterCapacityL(e.target.value)}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
</div>
</div>
<div className="sails-section">
<h3>{t('vessel.sails_list')}</h3>
<p className="help-text">{t('vessel.sails_help')}</p>
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
DEMO_EXCLUDED_STEPS,
DEMO_STEP_ORDER,
FULL_STEP_ORDER,
getTourScrollRetryDelays,
getTourTargetRetryDelay,
tourStepOpensEntry
} from './AppTourContext.tsx'
describe('AppTourContext step order', () => {
it('includes profile steps before finish in full tour', () => {
const profileIndex = FULL_STEP_ORDER.indexOf('nav_profile')
const prefsIndex = FULL_STEP_ORDER.indexOf('profile_preferences')
const finishIndex = FULL_STEP_ORDER.indexOf('finish')
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
expect(prefsIndex).toBe(profileIndex + 1)
expect(finishIndex).toBe(prefsIndex + 1)
expect(FULL_STEP_ORDER).toHaveLength(12)
})
it('excludes profile, stats and feedback from demo tour', () => {
for (const step of DEMO_EXCLUDED_STEPS) {
expect(DEMO_STEP_ORDER).not.toContain(step)
}
expect(DEMO_STEP_ORDER).toContain('finish')
expect(DEMO_STEP_ORDER).toHaveLength(FULL_STEP_ORDER.length - DEMO_EXCLUDED_STEPS.length)
})
it('only opens entry editor on entry_track step', () => {
expect(tourStepOpensEntry('entry_open')).toBe(false)
expect(tourStepOpensEntry('entry_list')).toBe(false)
expect(tourStepOpensEntry('entry_track')).toBe(true)
})
it('retries scroll for entry_track while editor mounts', () => {
expect(getTourTargetRetryDelay('entry_track')).toBeGreaterThanOrEqual(400)
expect(getTourScrollRetryDelays('entry_track').length).toBeGreaterThan(1)
})
})
+119 -15
View File
@@ -29,12 +29,17 @@ export type TourStepId =
| 'nav_crew'
| 'nav_stats'
| 'nav_feedback'
| 'nav_profile'
| 'profile_preferences'
| 'finish'
interface TourNavigation {
setActiveTab: (tab: AppTab) => void
setSelectedEntryId: (entryId: string | null) => void
setFeedbackOpen: (open: boolean) => void
setLogbookActive: (active: boolean) => void
setProfileOpen: (open: boolean) => void
ensureLogbookForTour?: () => Promise<void>
}
interface DemoTourContext {
@@ -47,6 +52,7 @@ interface AppTourContextValue {
currentStepId: TourStepId | null
currentStepIndex: number
totalSteps: number
layoutTick: number
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
stopTour: () => void
restartTour: () => void
@@ -58,7 +64,7 @@ interface AppTourContextValue {
requestStartAfterLogin: () => void
}
const FULL_STEP_ORDER: TourStepId[] = [
export const FULL_STEP_ORDER: TourStepId[] = [
'welcome',
'nav_logs',
'entry_list',
@@ -68,12 +74,33 @@ const FULL_STEP_ORDER: TourStepId[] = [
'nav_crew',
'nav_stats',
'nav_feedback',
'nav_profile',
'profile_preferences',
'finish'
]
/** Public demo has no stats/feedback UI — skip those steps. */
const DEMO_EXCLUDED_STEPS: TourStepId[] = ['nav_stats', 'nav_feedback']
const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter((id) => !DEMO_EXCLUDED_STEPS.includes(id))
/** Public demo has no stats/feedback/profile UI — skip those steps. */
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
'nav_stats',
'nav_feedback',
'nav_profile',
'profile_preferences'
]
export const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter(
(id) => !DEMO_EXCLUDED_STEPS.includes(id)
)
const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
'nav_logs',
'entry_list',
'entry_open',
'entry_track',
'nav_vessel',
'nav_crew',
'nav_stats',
'nav_feedback'
])
function getStepOrder(demoMode: boolean): TourStepId[] {
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
@@ -87,7 +114,28 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
nav_vessel: '[data-tour="nav-vessel"]',
nav_crew: '[data-tour="nav-crew"]',
nav_stats: '[data-tour="stats-dashboard"]',
nav_feedback: '[data-tour="feedback-form"]'
nav_feedback: '[data-tour="feedback-form"]',
nav_profile: '[data-tour="nav-profile"]',
profile_preferences: '[data-tour="profile-preferences"]'
}
/** Whether a tour step opens the first log entry editor (not the list card). */
export function tourStepOpensEntry(stepId: TourStepId): boolean {
return stepId === 'entry_track'
}
export function getTourTargetDelay(stepId: TourStepId): number {
if (stepId === 'entry_track') return 400
if (stepId === 'nav_feedback') return 180
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
return 0
}
/** Extra scroll attempts while async UI (e.g. entry editor) mounts. */
export function getTourScrollRetryDelays(stepId: TourStepId): number[] {
if (stepId === 'entry_track') return [400, 700, 1100, 1600]
const initial = getTourTargetDelay(stepId)
return initial > 0 ? [initial] : [0]
}
const AppTourContext = createContext<AppTourContextValue | null>(null)
@@ -97,6 +145,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const [stepIndex, setStepIndex] = useState(0)
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
const [isDemoTour, setIsDemoTour] = useState(false)
const [layoutTick, setLayoutTick] = useState(0)
const navigationRef = useRef<TourNavigation | null>(null)
const demoContextRef = useRef<DemoTourContext | null>(null)
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
@@ -112,13 +161,24 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const nav = navigationRef.current
if (!nav) return
if (LOGBOOK_TOUR_STEPS.has(stepId)) {
nav.setProfileOpen(false)
nav.setLogbookActive(true)
}
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
nav.setActiveTab('logs')
}
if (stepId === 'entry_open' || stepId === 'entry_track') {
if (stepId === 'entry_list' || stepId === 'entry_open') {
nav.setSelectedEntryId(null)
} else if (tourStepOpensEntry(stepId)) {
const firstEntryId = resolveFirstEntryId()
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
} else if (LOGBOOK_TOUR_STEPS.has(stepId)) {
nav.setSelectedEntryId(null)
}
if (stepId === 'nav_vessel') {
nav.setSelectedEntryId(null)
nav.setActiveTab('vessel')
@@ -137,19 +197,34 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
} else {
nav.setFeedbackOpen(false)
}
if (stepId === 'nav_profile') {
nav.setProfileOpen(false)
nav.setLogbookActive(false)
}
if (stepId === 'profile_preferences') {
nav.setLogbookActive(false)
nav.setProfileOpen(true)
}
}, [resolveFirstEntryId])
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
if (!stepId) return
const selector = TARGET_BY_STEP[stepId]
if (!selector) return
const delayMs = stepId === 'nav_feedback' ? 180 : 0
window.setTimeout(() => {
window.requestAnimationFrame(() => {
const el = document.querySelector(selector)
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
})
}, delayMs)
for (const delayMs of getTourScrollRetryDelays(stepId)) {
window.setTimeout(() => {
window.requestAnimationFrame(() => {
const el = document.querySelector(selector)
el?.scrollIntoView({
behavior: stepId === 'entry_track' ? 'instant' : 'smooth',
block: 'center',
inline: 'nearest'
})
})
}, delayMs)
}
}, [])
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
@@ -173,6 +248,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
const nav = navigationRef.current
if (nav && !tourModeRef.current.demoMode) {
nav.setProfileOpen(false)
nav.setLogbookActive(true)
nav.setSelectedEntryId(null)
nav.setActiveTab('stats')
}
@@ -183,6 +260,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
tourModeRef.current = { demoMode: false }
navigationRef.current?.setFeedbackOpen(false)
navigationRef.current?.setProfileOpen(false)
setIsDemoTour(false)
setIsActive(false)
setStepIndex(0)
@@ -213,8 +291,25 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
if (!isActive) return
const stepId = getStepOrder(isDemoTour)[stepIndex]
if (!stepId) return
applyStepSideEffects(stepId)
scrollToCurrentTarget(stepId)
let cancelled = false
const run = async () => {
if (LOGBOOK_TOUR_STEPS.has(stepId) && !isDemoTour) {
await navigationRef.current?.ensureLogbookForTour?.()
}
if (cancelled) return
applyStepSideEffects(stepId)
scrollToCurrentTarget(stepId)
setLayoutTick((tick) => tick + 1)
window.setTimeout(() => {
if (!cancelled) setLayoutTick((tick) => tick + 1)
}, 150)
}
void run()
return () => {
cancelled = true
}
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
const restartTour = useCallback(() => {
@@ -257,6 +352,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
currentStepId,
currentStepIndex: stepIndex,
totalSteps: stepOrder.length,
layoutTick,
startTour,
stopTour,
restartTour,
@@ -281,6 +377,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
startTour,
stepIndex,
stepOrder.length,
layoutTick,
stopTour
]
)
@@ -321,3 +418,10 @@ export function getTourTargetSelector(stepId: TourStepId | null): string | null
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
return stepId === 'welcome' || stepId === 'finish'
}
export function getTourTargetRetryDelay(stepId: TourStepId | null): number {
if (stepId === 'entry_track') return 400
if (stepId === 'profile_preferences') return 300
if (stepId === 'nav_profile') return 200
return 120
}
@@ -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 }
}
+91 -16
View File
@@ -1,11 +1,19 @@
import { useEffect, useRef } from 'react'
import { useRegisterSW } from 'virtual:pwa-register/react'
import {
forcePwaRecovery,
markReloadAttempt,
recentlyAttemptedReload,
triggerServiceWorkerUpdate
} from '../services/pwaStartup.js'
import { isDeployedVersionNewer } from '../services/pwaVersion.js'
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
const UPDATE_CHECK_INTERVAL_MS = 15 * 60 * 1000
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
const UPDATE_SUPPRESS_MS = 30_000
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
const UPDATE_RELOAD_FALLBACK_MS = 2000
const UPDATE_DISMISS_SUPPRESS_MS = 15 * 60 * 1000
const UPDATE_RELOAD_FALLBACK_MS = 2_000
const UPDATE_HARD_RECOVERY_MS = 5_000
function isUpdateSuppressed(): boolean {
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
@@ -20,10 +28,16 @@ function clearUpdateSuppression(): void {
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
}
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
function scheduleUpdateChecks(
registration: ServiceWorkerRegistration,
onOutdated: () => void
): () => void {
const checkForUpdate = () => {
if (isUpdateSuppressed()) return
registration.update().catch(() => {})
void isDeployedVersionNewer().then((outdated) => {
if (outdated) onOutdated()
})
}
const onVisibilityChange = () => {
@@ -32,52 +46,100 @@ function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => vo
}
}
const onOnline = () => {
checkForUpdate()
}
document.addEventListener('visibilitychange', onVisibilityChange)
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
window.addEventListener('online', onOnline)
const updateIntervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
checkForUpdate()
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.clearInterval(intervalId)
window.removeEventListener('online', onOnline)
window.clearInterval(updateIntervalId)
}
}
function reloadForServiceWorkerTakeover(): void {
if (recentlyAttemptedReload()) return
markReloadAttempt()
clearUpdateSuppression()
window.location.reload()
}
export function usePwaUpdate() {
const cleanupRef = useRef<(() => void) | null>(null)
const reloadFallbackTimerRef = useRef<number | null>(null)
const forceRecoveryTimerRef = useRef<number | null>(null)
const setNeedRefreshRef = useRef<((value: boolean) => void) | null>(null)
const pendingNeedRefreshRef = useRef<boolean | null>(null)
const applyNeedRefresh = (value: boolean) => {
if (setNeedRefreshRef.current) {
setNeedRefreshRef.current(value)
return
}
pendingNeedRefreshRef.current = value
}
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker
} = useRegisterSW({
immediate: true,
immediate: !import.meta.env.DEV,
onNeedReload() {
clearUpdateSuppression()
setNeedRefresh(false)
window.location.reload()
reloadForServiceWorkerTakeover()
},
onNeedRefresh() {
if (isUpdateSuppressed()) return
setNeedRefresh(true)
applyNeedRefresh(true)
},
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
if (!registration) return
if (isUpdateSuppressed() || !registration.waiting) {
setNeedRefresh(false)
applyNeedRefresh(false)
}
cleanupRef.current?.()
cleanupRef.current = scheduleUpdateChecks(registration)
cleanupRef.current = scheduleUpdateChecks(registration, () => {
if (isUpdateSuppressed()) return
applyNeedRefresh(true)
})
}
})
setNeedRefreshRef.current = setNeedRefresh
useEffect(() => {
if (isUpdateSuppressed()) {
setNeedRefresh(false)
} else if (pendingNeedRefreshRef.current !== null) {
const pending = pendingNeedRefreshRef.current
pendingNeedRefreshRef.current = null
setNeedRefresh(pending)
}
void isDeployedVersionNewer().then((outdated) => {
if (outdated) {
setNeedRefresh(true)
}
})
return () => {
cleanupRef.current?.()
cleanupRef.current = null
if (reloadFallbackTimerRef.current !== null) {
window.clearTimeout(reloadFallbackTimerRef.current)
reloadFallbackTimerRef.current = null
}
if (forceRecoveryTimerRef.current !== null) {
window.clearTimeout(forceRecoveryTimerRef.current)
forceRecoveryTimerRef.current = null
}
}
}, [setNeedRefresh])
@@ -86,11 +148,24 @@ export function usePwaUpdate() {
suppressUpdatePrompt()
await updateServiceWorker(true)
await triggerServiceWorkerUpdate()
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
window.setTimeout(() => {
window.location.reload()
if (reloadFallbackTimerRef.current !== null) {
window.clearTimeout(reloadFallbackTimerRef.current)
}
if (forceRecoveryTimerRef.current !== null) {
window.clearTimeout(forceRecoveryTimerRef.current)
}
reloadFallbackTimerRef.current = window.setTimeout(() => {
reloadFallbackTimerRef.current = null
reloadForServiceWorkerTakeover()
}, UPDATE_RELOAD_FALLBACK_MS)
forceRecoveryTimerRef.current = window.setTimeout(() => {
forceRecoveryTimerRef.current = null
void forcePwaRecovery()
}, UPDATE_HARD_RECOVERY_MS)
}
const dismissUpdate = () => {
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '../services/db.js'
import { subscribeToSyncState } from '../services/sync.js'
export type SyncConnStatusVariant = 'offline' | 'syncing' | 'pending' | 'online'
/** Maps sync/online state to conn-status CSS modifier classes. */
export function syncConnStatusClassName(
online: boolean,
showSpinner: boolean,
pendingCount: number
): string {
if (!online) return 'conn-status offline'
if (showSpinner) return 'conn-status syncing'
if (pendingCount > 0) return 'conn-status warning'
return 'conn-status online'
}
/** Sync queue depth and whether a sync pass is running (for header indicators). */
export function useSyncIndicator(logbookId?: string | null) {
const [isSyncing, setIsSyncing] = useState(false)
const pendingCount =
useLiveQuery(
() =>
logbookId
? db.syncQueue.where({ logbookId }).count()
: db.syncQueue.count(),
[logbookId]
) ?? 0
useEffect(() => {
return subscribeToSyncState(setIsSyncing)
}, [])
const showSpinner = isSyncing
const showPendingWarning = pendingCount > 0 && !isSyncing
return {
isSyncing,
pendingCount,
showSpinner,
showPendingWarning,
connStatusClassName: (online: boolean) =>
syncConnStatusClassName(online, showSpinner, pendingCount)
}
}
+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 { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import enTranslation from './locales/en.json'
import deTranslation from './locales/de.json'
import enJson from './locales/en.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
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: enTranslation,
de: deTranslation
},
resources,
defaultNS: 'translation',
fallbackLng: 'en',
supportedLngs: ['de', 'en'],
nonExplicitSupportedLngs: true,
load: 'languageOnly',
interpolation: {
escapeValue: false // React already escapes values (prevents XSS)
},
detection: {
order: ['localStorage', 'navigator'],
order: ['querystring', 'localStorage', 'navigator'],
lookupQuerystring: 'lng',
caches: ['localStorage']
}
})
initSeo(i18n)
export default i18n
+218 -37
View File
@@ -2,7 +2,15 @@
"translation": {
"app": {
"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": {
"dashboard": "Dashboard",
@@ -15,7 +23,7 @@
},
"auth": {
"welcome": "Willkommen bei Kapteins Daagbok",
"tagline": "Sicheres, E2E-verschlüsseltes maritimes Logbuch.",
"tagline": "Dein sicheres, E2E-verschlüsseltes maritimes Logbuch.",
"register": "Mit Passkey registrieren",
"login": "Mit Passkey anmelden",
"login_as": "Anmelden als {{name}}",
@@ -76,6 +84,7 @@
},
"sync": {
"status_synced": "Synchronisiert",
"status_syncing": "Synchronisiere…",
"status_offline": "Offline-Cache",
"status_unsynced": "Unsynchronisierte Änderungen"
},
@@ -108,7 +117,13 @@
"no_sails": "Keine Segel hinterlegt.",
"photo_add": "Foto hinzufügen",
"photo_change": "Foto ändern",
"photo_delete": "Foto löschen"
"photo_delete": "Foto löschen",
"tanks_section": "Tanks (Fassungsvermögen)",
"tanks_help": "Optional in Liter — ermöglicht Slider im Journal bei bekannten Tankgrößen.",
"freshwater_capacity_l": "Trinkwasser (Liter)",
"fuel_capacity_l": "Treibstoff (Liter)",
"greywater_capacity_l": "Grauwasser (Liter)",
"invalid_tank_liters": "Ungültiger Zahlenwert — bitte Liter als Zahl eingeben (z. B. 200)."
},
"logs": {
"title": "Logbuch-Journal",
@@ -129,6 +144,10 @@
"route": "Reise von/nach",
"freshwater": "Frischwasser (Liter)",
"fuel": "Treibstoff / Fuel (Liter)",
"greywater": "Grauwasser (Liter)",
"greywater_level": "Füllstand",
"tank_slider_of_max": "{{current}} / {{max}} L",
"tank_capacity_tooltip": "Wenn in den Schiffsdaten die Tank-Fassungsvermögen (Liter) hinterlegt sind, kannst du Füllstände hier per Slider eingeben.",
"morning": "Stand morgens",
"refilled": "Nachgefüllt",
"evening": "Stand abends",
@@ -143,6 +162,7 @@
"sign_passkey_signing": "Passkey wird angefordert…",
"sign_passkey_signed": "Freigegeben von {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Passkey-Freigabe entfernen",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Klassisch",
@@ -173,7 +193,7 @@
"delete_entry": "Tag löschen",
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser-, Kraftstoff- und Grauwasser-Startstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L\nGrauwasser: {{greywater}} L",
"carry_over_tanks_yes": "Übernehmen",
"carry_over_tanks_no": "Mit 0 starten",
"event_title": "Chronologisches Ereignisprotokoll",
@@ -181,6 +201,23 @@
"event_time": "Uhrzeit",
"event_mgk": "MgK 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_strength": "Windstärke",
"event_sea_state": "Seegang",
@@ -196,6 +233,10 @@
"event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor",
"motor_propulsion": "Maschinenfahrt",
"sails_picker_show_more": "Alle Segel anzeigen",
"sails_picker_show_less": "Weniger anzeigen",
"motor_hours": "Maschinenstunden (gesamt)",
"fuel_per_motor_hour": "Verbrauch pro Maschinenstunde",
"event_distance": "Distanz (sm)",
"export_csv": "CSV herunterladen",
"share_csv": "CSV teilen",
@@ -258,11 +299,150 @@
"role_crew": "Crew-Zugang",
"role_crew_hint": "Eingeladenes Logbuch — du kannst als Crew mitarbeiten und signieren",
"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",
"filter_label": "Logbücher filtern",
"filter_placeholder": "Name, Jahr oder Datum …",
"filter_clear": "Filter zurücksetzen",
"filter_results": "{{count}} Treffer",
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
"sort_label": "Sortieren",
"sort_by_label": "Sortieren nach",
"sort_by_name": "Name",
"sort_by_date": "Datum",
"sort_dir_label": "Reihenfolge",
"sort_asc": "Aufsteigend",
"sort_desc": "Absteigend",
"sort_name_asc": "Name A bis Z",
"sort_name_desc": "Name Z bis A",
"sort_date_asc": "Älteste zuerst",
"sort_date_desc": "Neueste zuerst"
},
"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",
"appearance_title": "App & Darstellung",
"appearance_desc": "Design und Farbschema gelten für die gesamte App auf diesem Gerät.",
"theme_label": "Design-Stil der App",
"theme_auto": "Automatisch (OS-Erkennung)",
"theme_ocean": "Ocean (Glassmorphismus)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_label": "Hell- oder Dunkelmodus",
"color_scheme_auto": "Automatisch (System)",
"color_scheme_light": "Hell",
"color_scheme_dark": "Dunkel",
"integrations_title": "Integrationen",
"owm_key": "OpenWeatherMap API-Schlüssel",
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
"prefs_save": "Speichern",
"prefs_saving": "Wird gespeichert…",
"prefs_saved": "Gespeichert",
"tour_title": "App-Tour",
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
"tour_restart": "Tour erneut starten",
"push_title": "Push-Benachrichtigungen",
"push_desc": "Als Logbuch-Eigner wirst du benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.",
"push_enable": "Bei Crew-Änderungen benachrichtigen",
"push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
},
"crew": {
"title": "Skipper- & Crew-Profile",
"skipper_section": "Skipper-Profil",
"skipper_read_only_hint": "Das Skipper-Profil kann nur vom Logbuch-Eigner bearbeitet werden.",
"crew_section": "Crew-Liste",
"add_crew": "Crew-Mitglied hinzufügen",
"edit_crew": "Crew-Mitglied bearbeiten",
@@ -294,32 +474,17 @@
"loading": "Kalibrierungstabelle wird geladen..."
},
"settings": {
"title": "Systemeinstellungen",
"subtitle": "Konfiguriere externe Integrationen und Anmeldedaten.",
"owm_title": "Wetter-Integration",
"owm_key": "OpenWeatherMap API-Schlüssel",
"save": "Konfiguration speichern",
"saving": "Wird gespeichert...",
"saved": "Einstellungen erfolgreich gespeichert!",
"key_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel in den Einstellungen oder kontaktiere den Betreiber.",
"title": "Logbuch-Einstellungen",
"subtitle": "Teilen, Backup und Zusammenarbeit für dieses Logbuch.",
"select_logbook_hint": "Wähle ein Logbuch aus, um dessen Einstellungen zu bearbeiten.",
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
"weather_success": "Wetterdaten erfolgreich abgerufen!",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
"theme_title": "Design-Anpassung",
"theme_label": "Design-Stil der App",
"theme_auto": "Automatisch (OS-Erkennung)",
"theme_ocean": "Ocean (Glassmorphismus)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_title": "Erscheinungsbild",
"color_scheme_label": "Hell- oder Dunkelmodus (Standard: Systemeinstellung)",
"color_scheme_auto": "Automatisch (System)",
"color_scheme_light": "Hell",
"color_scheme_dark": "Dunkel",
"share_title": "Logbuch teilen (Schreibgeschützt)",
"share_desc": "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_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren",
@@ -331,18 +496,14 @@
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
"delete_account_confirm_no": "Abbrechen",
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte 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…",
"tour_title": "App-Tour",
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
"tour_restart": "Tour erneut starten",
"push_title": "Push-Benachrichtigungen",
"push_desc": "Als Logbuch-Eigner wirst du benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.",
"push_enable": "Bei Crew-Änderungen benachrichtigen",
"push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
"invite_push_prompt_ios_message": "Sobald Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), dann Push im Benutzerprofil aktivieren.",
"invite_push_prompt_enable": "Jetzt aktivieren",
"invite_push_prompt_later": "Später",
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"backup_title": "Backup & Wiederherstellung",
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
"backup_export_title": "Backup erstellen",
@@ -469,6 +630,9 @@
"travel_days": "Reisetage",
"sail_distance": "Unter Segel",
"motor_distance": "Maschinenfahrt",
"motor_hours_total": "Maschinenstunden gesamt",
"daily_motor_hours": "Maschinenstunden pro Reisetag",
"avg_motor_hours": "Ø Maschinenstunden pro Reisetag",
"unknown_propulsion": "Unbekannt",
"fuel_total": "Kraftstoff gesamt",
"water_total": "Wasser gesamt",
@@ -482,9 +646,12 @@
"avg_fuel": "Ø Kraftstoff",
"avg_water": "Ø Wasser",
"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",
"water_legend": "Wasser",
"unit_nm": "sm",
"unit_h": "h",
"unit_l": "L",
"day_label": "Tag {{day}}",
"account_logbooks": "Logbücher im Überblick",
@@ -537,11 +704,25 @@
"title": "Feedback senden",
"body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken auch nach der Tour jederzeit über das Symbol oben rechts."
},
"nav_profile": {
"title": "Dein Benutzerprofil",
"body": "Über den Skipper-Button oben erreichst du dein persönliches Profil unabhängig vom aktuellen Logbuch."
},
"profile_preferences": {
"title": "Konto & Darstellung",
"body": "Hier verwaltest du deine Konto-Identität, Theme und Hell/Dunkel-Modus. Die App-Tour kannst du jederzeit erneut starten. Passkeys und Sicherheitseinstellungen findest du weiter unten im Profil."
},
"finish": {
"title": "Alles klar!",
"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 im Benutzerprofil 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"
}
}
}
+217 -36
View File
@@ -2,7 +2,15 @@
"translation": {
"app": {
"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": {
"dashboard": "Dashboard",
@@ -76,6 +84,7 @@
},
"sync": {
"status_synced": "Synced",
"status_syncing": "Syncing…",
"status_offline": "Offline Cache",
"status_unsynced": "Unsynced changes"
},
@@ -108,7 +117,13 @@
"no_sails": "No sails defined.",
"photo_add": "Add Photo",
"photo_change": "Change Photo",
"photo_delete": "Delete Photo"
"photo_delete": "Delete Photo",
"tanks_section": "Tanks (capacity)",
"tanks_help": "Optional, in liters — enables sliders in the journal when tank sizes are known.",
"freshwater_capacity_l": "Freshwater (liters)",
"fuel_capacity_l": "Fuel (liters)",
"greywater_capacity_l": "Greywater (liters)",
"invalid_tank_liters": "Invalid number — please enter capacity in liters (e.g. 200)."
},
"logs": {
"title": "Logbook Journal",
@@ -129,6 +144,10 @@
"route": "Route / Journey",
"freshwater": "Freshwater (Liters)",
"fuel": "Fuel (Liters)",
"greywater": "Greywater (Liters)",
"greywater_level": "Fill level",
"tank_slider_of_max": "{{current}} / {{max}} L",
"tank_capacity_tooltip": "If tank capacities (liters) are set in vessel master data, you can enter fill levels here using sliders.",
"morning": "Morning Level",
"refilled": "Refilled",
"evening": "Evening Level",
@@ -143,6 +162,7 @@
"sign_passkey_signing": "Requesting Passkey…",
"sign_passkey_signed": "Signed by {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Remove Passkey signature",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Classic",
@@ -173,7 +193,7 @@
"delete_entry": "Delete Day",
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
"carry_over_tanks_title": "Carry over from previous day?",
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L\nGreywater: {{greywater}} L",
"carry_over_tanks_yes": "Carry over",
"carry_over_tanks_no": "Start at 0",
"event_title": "Chronological Event Logbook",
@@ -181,6 +201,23 @@
"event_time": "Time",
"event_mgk": "MgK 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_strength": "Wind Str",
"event_sea_state": "Sea State",
@@ -196,6 +233,10 @@
"event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status",
"motor_propulsion": "Engine Propulsion",
"sails_picker_show_more": "Show all sails",
"sails_picker_show_less": "Show less",
"motor_hours": "Engine hours (total)",
"fuel_per_motor_hour": "Consumption per engine hour",
"event_distance": "Distance (nm)",
"export_csv": "Download CSV",
"share_csv": "Share CSV",
@@ -258,11 +299,150 @@
"role_crew": "Crew access",
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
"role_read": "Read only",
"role_read_hint": "Shared logbook — view only, no editing"
"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",
"filter_label": "Filter logbooks",
"filter_placeholder": "Name, year or date …",
"filter_clear": "Clear filter",
"filter_results": "{{count}} matches",
"filter_no_results": "No logbooks match your search. Try a different name or year.",
"sort_label": "Sort",
"sort_by_label": "Sort by",
"sort_by_name": "Name",
"sort_by_date": "Date",
"sort_dir_label": "Order",
"sort_asc": "Ascending",
"sort_desc": "Descending",
"sort_name_asc": "Name A to Z",
"sort_name_desc": "Name Z to A",
"sort_date_asc": "Oldest first",
"sort_date_desc": "Newest first"
},
"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",
"appearance_title": "App & appearance",
"appearance_desc": "Theme and color scheme apply to the entire app on this device.",
"theme_label": "Application style / theme",
"theme_auto": "Auto (OS detect)",
"theme_ocean": "Ocean (glassmorphism)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_label": "Light or dark mode",
"color_scheme_auto": "Auto (system)",
"color_scheme_light": "Light",
"color_scheme_dark": "Dark",
"integrations_title": "Integrations",
"owm_key": "OpenWeatherMap API key",
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
"prefs_save": "Save",
"prefs_saving": "Saving…",
"prefs_saved": "Saved",
"tour_title": "App tour",
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
"tour_restart": "Restart tour",
"push_title": "Push notifications",
"push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.",
"push_enable": "Notify on crew changes",
"push_active": "Push notifications are active on this device.",
"push_unsupported": "Push notifications are not supported in this browser.",
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
"push_error": "Could not enable push notifications."
},
"crew": {
"title": "Skipper & Crew Profiles",
"skipper_section": "Skipper Profile",
"skipper_read_only_hint": "The skipper profile can only be edited by the logbook owner.",
"crew_section": "Crew List",
"add_crew": "Add Crew Member",
"edit_crew": "Edit Crew Member",
@@ -294,32 +474,17 @@
"loading": "Loading calibration table..."
},
"settings": {
"title": "System Settings",
"subtitle": "Configure external integrations and client credentials.",
"owm_title": "Weather Integration",
"owm_key": "OpenWeatherMap API Key",
"save": "Save Configuration",
"saving": "Saving...",
"saved": "Settings saved successfully!",
"key_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
"no_key": "No OpenWeatherMap API key available. Add your own key in settings or contact the operator.",
"title": "Logbook settings",
"subtitle": "Sharing, backup, and collaboration for this logbook.",
"select_logbook_hint": "Select a logbook to edit its settings.",
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
"weather_success": "Weather details fetched successfully!",
"weather_error": "Failed to fetch weather. Check your API key and connection.",
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
"gps_error": "Please enter a location or fetch GPS coordinates first.",
"theme_title": "UI Customization",
"theme_label": "Application Style / Theme",
"theme_auto": "Auto (OS Detect)",
"theme_ocean": "Ocean (Glassmorphism)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_title": "Appearance",
"color_scheme_label": "Light or dark mode (default: follow system)",
"color_scheme_auto": "Auto (System)",
"color_scheme_light": "Light",
"color_scheme_dark": "Dark",
"share_title": "Share Logbook (Read-Only)",
"share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).",
"share_privacy_warning": "Recommendation: Share this link only privately (e.g. via email or messenger), not on social media.",
"share_enable": "Enable Public Link",
"share_copied": "Link copied!",
"share_copy_btn": "Copy Link",
@@ -331,18 +496,14 @@
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
"delete_account_confirm_no": "Cancel",
"delete_account_failed": "Failed to delete account. Please try again.",
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
"deleting_account": "Deleting account…",
"tour_title": "App tour",
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
"tour_restart": "Restart tour",
"push_title": "Push notifications",
"push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.",
"push_enable": "Notify on crew changes",
"push_active": "Push notifications are active on this device.",
"push_unsupported": "Push notifications are not supported in this browser.",
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
"push_error": "Could not enable push notifications.",
"invite_push_prompt_title": "Enable push notifications?",
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
"invite_push_prompt_ios_message": "When crew members sync changes, you can get push notifications. On iPhone/iPad: add the app to your Home Screen (iOS 16.4+), then enable push in your user profile.",
"invite_push_prompt_enable": "Enable now",
"invite_push_prompt_later": "Later",
"invite_push_prompt_success": "Push notifications are active on this device.",
"backup_title": "Backup & restore",
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
"backup_export_title": "Create backup",
@@ -469,6 +630,9 @@
"travel_days": "Travel days",
"sail_distance": "Under sail",
"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",
"fuel_total": "Total fuel",
"water_total": "Total water",
@@ -482,9 +646,12 @@
"avg_fuel": "Avg. fuel",
"avg_water": "Avg. water",
"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",
"water_legend": "Water",
"unit_nm": "nm",
"unit_h": "h",
"unit_l": "L",
"day_label": "Day {{day}}",
"account_logbooks": "Logbooks overview",
@@ -537,11 +704,25 @@
"title": "Send feedback",
"body": "Use this form to report bugs, ideas, or general feedback to the project team — you can also open it anytime later via the icon in the top right."
},
"nav_profile": {
"title": "Your user profile",
"body": "Tap the skipper button at the top to open your personal profile — independent of the current logbook."
},
"profile_preferences": {
"title": "Account & appearance",
"body": "Manage your account identity, theme, and light/dark mode here. You can restart the app tour anytime. Passkeys and security settings are further down on the profile page."
},
"finish": {
"title": "You're all set!",
"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 from your user profile. 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"
}
}
}
+11 -102
View File
@@ -1,64 +1,8 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
/* Minimal app shell — component styles live in App.css / themes.css */
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
*,
*::before,
*::after {
box-sizing: border-box;
}
@@ -66,46 +10,11 @@ body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
#root {
width: 100%;
max-width: 100%;
min-height: 100svh;
display: flex;
flex-direction: column;
align-items: center;
}
+65 -7
View File
@@ -3,14 +3,72 @@ import { createRoot } from 'react-dom/client'
import 'leaflet/dist/leaflet.css'
import './themes.css'
import './index.css'
import App from './App.tsx'
import './App.css'
import './i18n'
import App from './App.tsx'
import { applyAppearanceToDocument } from './services/appearance.ts'
import {
installStaleAssetRecovery,
markReloadAttempt,
reconcileVersionOnStartup
} from './services/pwaStartup.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(
<StrictMode>
<App />
</StrictMode>,
)
function renderBootstrapError(message: string): void {
const root = document.getElementById('root')
if (!root) return
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()
installStaleAssetRecovery()
await clearDevServiceWorkerCaches()
const startupResult = await reconcileVersionOnStartup()
if (startupResult === 'reload') {
markReloadAttempt()
window.location.reload()
return
}
if (startupResult === 'recovered') {
return
}
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.',
)
})
+10 -1
View File
@@ -25,7 +25,16 @@ export const PlausibleEvents = {
DEMO_OPENED: 'Demo Opened',
PUSH_ENABLED: 'Push Enabled',
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
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
+70
View File
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
applyAppearanceToDocument,
resolveAppTheme,
resolveColorScheme,
type AppTheme,
type ResolvedColorScheme
} from './appearance.js'
import { setColorSchemePreference } from './userPreferences.js'
const USER_ID = 'appearance-test-user'
const COMBOS: Array<{ theme: AppTheme; scheme: ResolvedColorScheme }> = [
{ theme: 'ocean', scheme: 'dark' },
{ theme: 'ocean', scheme: 'light' },
{ theme: 'material', scheme: 'dark' },
{ theme: 'material', scheme: 'light' },
{ theme: 'cupertino', scheme: 'dark' },
{ theme: 'cupertino', scheme: 'light' }
]
describe('appearance', () => {
beforeEach(() => {
localStorage.clear()
document.documentElement.className = ''
document.documentElement.style.colorScheme = ''
document.head.querySelector('meta[name="theme-color"]')?.remove()
})
it.each(COMBOS)('applies $theme · $scheme classes to document', ({ theme, scheme }) => {
applyAppearanceToDocument(theme, scheme)
const root = document.documentElement
expect(root.classList.contains(`theme-${theme}`)).toBe(true)
expect(root.classList.contains(`scheme-${scheme}`)).toBe(true)
expect(root.style.colorScheme).toBe(scheme)
})
it('replaces previous theme classes when switching appearance', () => {
applyAppearanceToDocument('ocean', 'dark')
applyAppearanceToDocument('material', 'light')
const root = document.documentElement
expect(root.classList.contains('theme-material')).toBe(true)
expect(root.classList.contains('theme-ocean')).toBe(false)
expect(root.classList.contains('scheme-light')).toBe(true)
expect(root.classList.contains('scheme-dark')).toBe(false)
})
it('resolves stored light scheme even when system prefers dark', () => {
vi.stubGlobal(
'matchMedia',
vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })
)
localStorage.setItem('active_userid', USER_ID)
setColorSchemePreference(USER_ID, 'light')
expect(resolveColorScheme()).toBe('light')
applyAppearanceToDocument('material', resolveColorScheme())
expect(document.documentElement.classList.contains('scheme-light')).toBe(true)
})
it('auto theme picks material on Android user agent', () => {
vi.stubGlobal('navigator', {
...navigator,
userAgent: 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36'
})
expect(resolveAppTheme()).toBe('material')
})
})
+17 -2
View File
@@ -1,3 +1,5 @@
import { getColorSchemePreference as getStoredColorScheme, getThemePreference } from './userPreferences.js'
export type ColorSchemePreference = 'auto' | 'light' | 'dark'
export type ResolvedColorScheme = 'light' | 'dark'
export type AppTheme = 'ocean' | 'material' | 'cupertino'
@@ -6,7 +8,7 @@ const THEME_CLASSES = ['theme-ocean', 'theme-material', 'theme-cupertino'] as co
const SCHEME_CLASSES = ['scheme-light', 'scheme-dark'] as const
export function getColorSchemePreference(): ColorSchemePreference {
const stored = localStorage.getItem('active_color_scheme')
const stored = getStoredColorScheme()
if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored
return 'auto'
}
@@ -19,7 +21,7 @@ export function resolveColorScheme(pref?: ColorSchemePreference): ResolvedColorS
}
export function resolveAppTheme(): AppTheme {
const configTheme = localStorage.getItem('active_theme') || 'auto'
const configTheme = getThemePreference() || 'auto'
if (configTheme === 'material' || configTheme === 'cupertino' || configTheme === 'ocean') {
return configTheme
}
@@ -29,6 +31,18 @@ export function resolveAppTheme(): AppTheme {
return 'ocean'
}
function updateThemeColorMeta(root: HTMLElement): void {
const color = getComputedStyle(root).getPropertyValue('--app-theme-color').trim()
if (!color) return
let meta = document.querySelector('meta[name="theme-color"]')
if (!meta) {
meta = document.createElement('meta')
meta.setAttribute('name', 'theme-color')
document.head.appendChild(meta)
}
meta.setAttribute('content', color)
}
export function applyAppearanceToDocument(
theme: AppTheme = resolveAppTheme(),
scheme: ResolvedColorScheme = resolveColorScheme()
@@ -37,6 +51,7 @@ export function applyAppearanceToDocument(
root.classList.remove(...THEME_CLASSES, ...SCHEME_CLASSES)
root.classList.add(`theme-${theme}`, `scheme-${scheme}`)
root.style.colorScheme = scheme
updateThemeColorMeta(root)
}
export function subscribeToSystemColorScheme(onChange: () => void): () => void {
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
fetchAppearancePrefs,
saveAppearancePrefsToServer,
syncAppearancePrefs
} from './appearancePrefs.js'
import { setThemePreference } from './userPreferences.js'
const USER_ID = 'appearance-sync-user'
vi.mock('./api.js', () => ({
apiJson: vi.fn()
}))
import { apiJson } from './api.js'
const mockedApiJson = vi.mocked(apiJson)
describe('appearancePrefs', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
it('fetchAppearancePrefs returns defaults when not authenticated', async () => {
await expect(fetchAppearancePrefs()).resolves.toEqual({
theme: 'auto',
colorScheme: 'auto',
persisted: false
})
expect(mockedApiJson).not.toHaveBeenCalled()
})
it('syncAppearancePrefs applies server prefs after cache wipe', async () => {
localStorage.setItem('active_userid', USER_ID)
mockedApiJson.mockResolvedValueOnce({
theme: 'ocean',
colorScheme: 'dark',
persisted: true
})
const changed = vi.fn()
window.addEventListener('appearance-changed', changed)
await syncAppearancePrefs(USER_ID)
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
expect(changed).toHaveBeenCalledTimes(1)
})
it('syncAppearancePrefs uploads local prefs when server has none', async () => {
localStorage.setItem('active_userid', USER_ID)
setThemePreference(USER_ID, 'material')
mockedApiJson
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
await syncAppearancePrefs(USER_ID)
expect(mockedApiJson).toHaveBeenCalledTimes(2)
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
method: 'PUT',
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' })
})
})
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
await saveAppearancePrefsToServer('ocean', 'light')
expect(mockedApiJson).not.toHaveBeenCalled()
})
it('syncAppearancePrefs skips server sync when userId does not match active session', async () => {
localStorage.setItem('active_userid', 'session-user')
setThemePreference('other-user', 'ocean')
mockedApiJson.mockResolvedValue({
theme: 'material',
colorScheme: 'dark',
persisted: true
})
await syncAppearancePrefs('other-user')
expect(mockedApiJson).not.toHaveBeenCalled()
expect(localStorage.getItem('user_pref_theme_other-user')).toBe('ocean')
})
it('syncAppearancePrefs skips server sync when active session is missing', async () => {
setThemePreference(USER_ID, 'ocean')
await syncAppearancePrefs(USER_ID)
expect(mockedApiJson).not.toHaveBeenCalled()
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
})
})
+76
View File
@@ -0,0 +1,76 @@
import { apiJson } from './api.js'
import { notifyAppearanceChanged } from './appearance.js'
import {
getActiveUserId,
getColorSchemePreference,
getThemePreference,
setColorSchemePreference,
setThemePreference
} from './userPreferences.js'
const API_BASE = '/api/auth/appearance-prefs'
export interface AppearancePrefs {
theme: string
colorScheme: string
persisted: boolean
}
function hasLocalAppearancePrefs(userId: string): boolean {
return (
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null
)
}
function resolveSyncedUserId(userId?: string | null): string | null {
const id = userId?.trim() || getActiveUserId()?.trim() || null
if (!id) return null
const activeId = getActiveUserId()?.trim() || null
if (!activeId || activeId !== id) return null
return id
}
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
if (!resolveSyncedUserId(userId)) {
return { theme: 'auto', colorScheme: 'auto', persisted: false }
}
return apiJson<AppearancePrefs>(API_BASE)
}
export async function saveAppearancePrefsToServer(
theme: string,
colorScheme: string,
userId?: string | null
): Promise<void> {
if (!resolveSyncedUserId(userId)) return
await apiJson<AppearancePrefs>(API_BASE, {
method: 'PUT',
body: JSON.stringify({ theme, colorScheme })
})
}
/** Merge server-stored appearance with local cache (server wins after cache wipe). */
export async function syncAppearancePrefs(userId?: string | null): Promise<void> {
const id = resolveSyncedUserId(userId)
if (!id) return
try {
const server = await fetchAppearancePrefs(id)
if (server.persisted) {
setThemePreference(id, server.theme)
setColorSchemePreference(id, server.colorScheme)
} else if (hasLocalAppearancePrefs(id)) {
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id)
}
} catch (err) {
console.warn('Failed to sync appearance preferences:', err)
}
notifyAppearanceChanged()
}
+158 -1
View File
@@ -33,10 +33,33 @@ export function setActiveMasterKey(key: ArrayBuffer | null) {
}
export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), 8_000)
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 {
return { authenticated: false }
} finally {
window.clearTimeout(timeoutId)
}
}
/** Master key + username in memory/storage — enough to stay in the unlocked UI. */
export function hasUnlockedLocalCrypto(): boolean {
return !!(getActiveMasterKey() && localStorage.getItem('active_username'))
}
/** Crypto unlock plus user id for authenticated API calls (userId may already be in localStorage). */
export function hasUnlockedLocalSession(): boolean {
return hasUnlockedLocalCrypto() && !!localStorage.getItem('active_userid')
}
/** Persist server session user id when the /session response includes it. */
export function persistSessionUserId(userId: string | undefined): void {
if (userId) {
localStorage.setItem('active_userid', userId)
}
}
@@ -543,3 +566,137 @@ export async function deleteAccount(): Promise<boolean> {
}
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
}
+53
View File
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
hasUnlockedLocalCrypto,
hasUnlockedLocalSession,
setActiveMasterKey
} from './auth.js'
describe('local session unlock checks', () => {
beforeEach(() => {
localStorage.clear()
setActiveMasterKey(null)
})
it('hasUnlockedLocalCrypto with master key and username only', () => {
setActiveMasterKey(new ArrayBuffer(32))
localStorage.setItem('active_username', 'skipper')
expect(hasUnlockedLocalCrypto()).toBe(true)
expect(hasUnlockedLocalSession()).toBe(false)
})
it('hasUnlockedLocalSession when userId is present', () => {
setActiveMasterKey(new ArrayBuffer(32))
localStorage.setItem('active_username', 'skipper')
localStorage.setItem('active_userid', 'user-1')
expect(hasUnlockedLocalCrypto()).toBe(true)
expect(hasUnlockedLocalSession()).toBe(true)
})
it('hasUnlockedLocalCrypto false without master key', () => {
localStorage.setItem('active_username', 'skipper')
localStorage.setItem('active_userid', 'user-1')
expect(hasUnlockedLocalCrypto()).toBe(false)
})
})
describe('persistSessionUserId', () => {
beforeEach(() => {
localStorage.clear()
})
it('stores userId when provided', async () => {
const { persistSessionUserId } = await import('./auth.js')
persistSessionUserId('user-42')
expect(localStorage.getItem('active_userid')).toBe('user-42')
})
it('does not clear existing userId when omitted', async () => {
const { persistSessionUserId } = await import('./auth.js')
localStorage.setItem('active_userid', 'user-1')
persistSessionUserId(undefined)
expect(localStorage.getItem('active_userid')).toBe('user-1')
})
})
+16 -5
View File
@@ -3,7 +3,9 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import i18n from '../i18n/index.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
function escapeCsvValue(val: string | number | undefined | null): string {
if (val === null || val === undefined) return '';
@@ -79,13 +81,14 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
'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',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
'Latitude', 'Longitude', 'Remarks',
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)',
'Greywater Level (L)',
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
];
@@ -93,8 +96,12 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const exportLabels = {
imagePlaceholder: i18n.t('logs.sign_export_image'),
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 })
},
attributionLabel: (username: string, signedAt: string) => {
const date = formatAppDateTime(signedAt, i18n.language)
return i18n.t('logs.sign_attribution_export', { username, date })
}
};
@@ -108,6 +115,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const trackDist = entry.trackDistanceNm ?? '';
const trackMax = entry.trackSpeedMaxKn ?? '';
const trackAvg = entry.trackSpeedAvgKn ?? '';
const motorH = entry.motorHours ?? '';
const fwM = entry.freshwater?.morning ?? '';
const fwR = entry.freshwater?.refilled ?? '';
const fwE = entry.freshwater?.evening ?? '';
@@ -116,6 +124,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const fuelR = entry.fuel?.refilled ?? '';
const fuelE = entry.fuel?.evening ?? '';
const fuelCons = entry.fuel?.consumption ?? '';
const greywaterLevel = entry.greywater?.level ?? '';
const eventsList = entry.events || [];
if (eventsList.length === 0) {
@@ -123,29 +132,31 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([
dateVal, travelDay, dep, dest,
signS, signC,
trackDist, trackMax, trackAvg,
trackDist, trackMax, trackAvg, motorH,
'', '', '',
'', '', '', '',
'', '', '', '', '',
'', '', '',
fwM, fwR, fwE, fwCons,
fuelM, fuelR, fuelE, fuelCons,
greywaterLevel,
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
].map(escapeCsvValue));
} else {
// 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) {
rows.push([
dateVal, travelDay, dep, dest,
signS, signC,
trackDist, trackMax, trackAvg,
trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
fwM, fwR, fwE, fwCons,
fuelM, fuelR, fuelE, fuelCons,
greywaterLevel,
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
].map(escapeCsvValue));
}
+36
View File
@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
clearDemoLogbookRefs,
getDemoFirstEntryStorageKey,
getDemoLogbookStorageKey
} from './demoLogbook.js'
describe('clearDemoLogbookRefs', () => {
const userId = 'user-1'
beforeEach(() => {
localStorage.clear()
localStorage.setItem('active_userid', userId)
})
it('removes demo logbook and first-entry keys for the user', () => {
const logbookId = 'lb-demo'
localStorage.setItem(getDemoLogbookStorageKey(userId), logbookId)
localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1')
clearDemoLogbookRefs(userId, logbookId)
expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBeNull()
expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBeNull()
})
it('does not clear refs when logbookId does not match stored demo id', () => {
localStorage.setItem(getDemoLogbookStorageKey(userId), 'other-logbook')
localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1')
clearDemoLogbookRefs(userId, 'deleted-logbook')
expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBe('other-logbook')
expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBe('entry-1')
})
})
+64
View File
@@ -108,6 +108,7 @@ export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null>
const title = i18n.t('demo.logbook_title')
return { logbookId: existingId, title, firstEntryId }
}
clearDemoLogbookRefs(userId, existingId)
}
if (!shouldSeed) return null
@@ -152,3 +153,66 @@ export function getStoredDemoFirstEntryId(): string | null {
if (!userId) return null
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
}
/** Remove persisted demo logbook pointers when the logbook no longer exists. */
export function clearDemoLogbookRefs(userId: string, logbookId?: string): void {
const storedId = localStorage.getItem(getDemoLogbookStorageKey(userId))
if (logbookId && storedId && storedId !== logbookId) return
localStorage.removeItem(getDemoLogbookStorageKey(userId))
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
}
export async function entryExistsInLogbook(logbookId: string, entryId: string): Promise<boolean> {
const entry = await db.entries.get(entryId)
return entry?.logbookId === logbookId
}
export interface TourLogbookContext {
logbookId: string
title: string
firstEntryId: string | null
}
/** Pick a logbook + first entry for the onboarding tour (handles deleted demo data). */
export async function resolveTourLogbookContext(
preferLogbookId?: string | null
): Promise<TourLogbookContext | null> {
const userId = localStorage.getItem('active_userid')
if (!userId || !getActiveMasterKey()) return null
const demoId = localStorage.getItem(getDemoLogbookStorageKey(userId))
if (demoId && !(await db.logbooks.get(demoId))) {
clearDemoLogbookRefs(userId, demoId)
}
const { fetchLogbooks } = await import('./logbook.js')
const books = await fetchLogbooks()
if (books.length === 0) return null
const activeId = localStorage.getItem('active_logbook_id')
const pick =
(preferLogbookId ? books.find((b) => b.id === preferLogbookId) : undefined) ??
(activeId ? books.find((b) => b.id === activeId) : undefined) ??
(demoId ? books.find((b) => b.id === demoId) : undefined) ??
books[0]
const firstEntryId = await resolveTourFirstEntryId(pick.id, userId)
return { logbookId: pick.id, title: pick.title, firstEntryId }
}
async function resolveTourFirstEntryId(logbookId: string, userId: string): Promise<string | null> {
const stored = localStorage.getItem(getDemoFirstEntryStorageKey(userId))
if (stored && (await entryExistsInLogbook(logbookId, stored))) {
return stored
}
if (stored) {
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
}
const localEntries = await db.entries.where({ logbookId }).toArray()
if (localEntries.length === 0) return null
localEntries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
return localEntries[0]?.payloadId ?? null
}
+24 -1
View File
@@ -26,6 +26,8 @@ export interface DemoDaySpec {
filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
greywaterLevel?: number
motorHours?: number
events: Array<Record<string, string>>
}
@@ -68,6 +70,7 @@ export function buildDemoDays(): DemoDaySpec[] {
filename: 'kiel-laboe.gpx',
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
greywaterLevel: 25,
events: [
{
time: '10:15',
@@ -100,6 +103,8 @@ export function buildDemoDays(): DemoDaySpec[] {
filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
greywaterLevel: 38,
motorHours: 1.5,
events: [
{
time: '09:00',
@@ -132,6 +137,7 @@ export function buildDemoDays(): DemoDaySpec[] {
filename: 'damp-schleimuende.gpx',
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
greywaterLevel: 52,
events: [
{
time: '08:30',
@@ -174,7 +180,10 @@ export function buildDemoYachtData(): Record<string, unknown> {
atis: '',
mmsi: '',
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
photo: null
photo: null,
freshwaterCapacityL: 200,
fuelCapacityL: 100,
greywaterCapacityL: 80
}
}
@@ -242,11 +251,18 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
events: day.events
}
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
entryPayload.greywater = { level: day.greywaterLevel }
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
if (day.motorHours != null && day.motorHours > 0) {
entryPayload.motorHours = day.motorHours
}
entries.push(entryPayload as PublicDemoFixture['entries'][number])
@@ -298,11 +314,18 @@ export function buildDemoEntryPayloads(): Array<{
events: day.events
}
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
entryPayload.greywater = { level: day.greywaterLevel }
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
if (day.motorHours != null && day.motorHours > 0) {
entryPayload.motorHours = day.motorHours
}
return {
entryId,
+65
View File
@@ -4,6 +4,7 @@ import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { apiFetch } from './api.js'
import { clearDemoLogbookRefs, getDemoLogbookStorageKey } from './demoLogbook.js'
const API_BASE = '/api/logbooks'
@@ -320,5 +321,69 @@ export async function deleteLogbook(id: string): Promise<void> {
// Perform local cascading cleanup
await deleteLocalLogbookCache(id)
if (userId && id === localStorage.getItem(getDemoLogbookStorageKey(userId))) {
clearDemoLogbookRefs(userId, id)
}
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
})
}
+28 -11
View File
@@ -3,12 +3,13 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.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 { formatAppDateTime } from '../utils/dateTimeFormat.js'
function formatPasskeySignDate(signedAt: string): string {
const locale = i18n.language === 'de' ? 'de-DE' : 'en-GB'
return new Date(signedAt).toLocaleString(locale)
return formatAppDateTime(signedAt, i18n.language)
}
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
const events = entry.events || [];
const maxRows = 16;
const sortedEvents = [...events].sort((a: any, b: any) => (a.time || '').localeCompare(b.time || ''));
const sortedEvents = sortLogEventsByTime(events);
doc.setFont('Helvetica', 'normal');
@@ -196,13 +197,15 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.text('VERBRAUCHSWERTE / CONSUMPTON STATS', 10, footerY + 3);
let fwY = footerY + 5;
doc.rect(10, fwY, 110, rowHeight * 3, 'S');
const tankRows = 4;
doc.rect(10, fwY, 110, rowHeight * tankRows, 'S');
doc.line(10, fwY + rowHeight, 120, fwY + rowHeight);
doc.line(10, fwY + rowHeight * 2, 120, fwY + rowHeight * 2);
doc.line(40, fwY, 40, fwY + rowHeight * 3);
doc.line(60, fwY, 60, fwY + rowHeight * 3);
doc.line(80, fwY, 80, fwY + rowHeight * 3);
doc.line(100, fwY, 100, fwY + rowHeight * 3);
doc.line(10, fwY + rowHeight * 3, 120, fwY + rowHeight * 3);
doc.line(40, fwY, 40, fwY + rowHeight * tankRows);
doc.line(60, fwY, 60, fwY + rowHeight * tankRows);
doc.line(80, fwY, 80, fwY + rowHeight * tankRows);
doc.line(100, fwY, 100, fwY + rowHeight * tankRows);
doc.setFont('Helvetica', 'bold');
doc.setFontSize(7.5);
@@ -225,6 +228,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.text(String(entry.fuel?.evening ?? '0'), 81, fwY + rowHeight * 2 + 4.2);
doc.text(String(entry.fuel?.consumption ?? '0'), 101, fwY + rowHeight * 2 + 4.2);
doc.text('Grauwasser', 11, fwY + rowHeight * 3 + 4.2);
doc.text('—', 41, fwY + rowHeight * 3 + 4.2);
doc.text('—', 61, fwY + rowHeight * 3 + 4.2);
doc.text(String(entry.greywater?.level ?? '0'), 81, fwY + rowHeight * 3 + 4.2);
doc.text('—', 101, fwY + rowHeight * 3 + 4.2);
// Signatures Box
let sigX = 130;
let sigY = footerY + 5;
@@ -255,8 +264,16 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
doc.text(`Passkey: ${entry.signCrew.username}`, sigX + 80.5, sigY + 9);
doc.text(crewDate, sigX + 80.5, sigY + 13.5);
} else if (isSignatureImage(entry.signCrew)) {
doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
} else if (isClassicSignature(entry.signCrew)) {
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 {
doc.setFont('Helvetica', 'normal');
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
+12
View File
@@ -43,6 +43,18 @@ async function fetchVapidPublicKey(): Promise<string | null> {
}
}
/** True when crew-change push is enabled and notification permission is granted. */
export async function isCollaboratorPushActive(): Promise<boolean> {
if (!isPushSupported()) return false
if (getNotificationPermission() !== 'granted') return false
try {
const prefs = await fetchPushPrefs()
return prefs.collaboratorChangesEnabled
} catch {
return false
}
}
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
if (!localStorage.getItem('active_userid')) {
return { collaboratorChangesEnabled: false }
+112
View File
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
forcePwaRecovery,
markReloadAttempt,
recentlyAttemptedReload,
reconcileServiceWorkerOnStartup,
reconcileVersionOnStartup
} from './pwaStartup.js'
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count'
const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts'
describe('pwaStartup reload guards', () => {
beforeEach(() => {
sessionStorage.clear()
})
it('blocks repeated reload attempts within the debounce window', () => {
expect(recentlyAttemptedReload(10_000)).toBe(false)
markReloadAttempt(10_000)
expect(recentlyAttemptedReload(12_000)).toBe(true)
expect(recentlyAttemptedReload(15_000)).toBe(false)
})
})
describe('forcePwaRecovery stale counter reset', () => {
beforeEach(() => {
sessionStorage.clear()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('clears stale recovery counter before hard recovery reload', async () => {
vi.stubEnv('DEV', false)
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, '2')
sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(Date.now()))
const reload = vi.fn()
vi.stubGlobal('location', { reload })
vi.stubGlobal('caches', {
keys: vi.fn().mockResolvedValue([]),
delete: vi.fn()
})
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
getRegistrations: vi.fn().mockResolvedValue([])
}
})
await forcePwaRecovery()
expect(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY)).toBeNull()
expect(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY)).toBeNull()
expect(reload).toHaveBeenCalledOnce()
})
})
describe('reconcileServiceWorkerOnStartup', () => {
beforeEach(() => {
sessionStorage.clear()
vi.unstubAllEnvs()
})
it('returns false in dev mode', async () => {
vi.stubEnv('DEV', true)
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
})
it('returns false when no waiting worker exists', async () => {
vi.stubEnv('DEV', false)
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
controller: {},
getRegistration: vi.fn().mockResolvedValue({
waiting: null,
installing: null,
update: vi.fn().mockResolvedValue(undefined),
addEventListener: vi.fn()
}),
addEventListener: vi.fn()
}
})
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
})
})
describe('reconcileVersionOnStartup', () => {
beforeEach(() => {
sessionStorage.clear()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('returns noop in dev mode', async () => {
vi.stubEnv('DEV', true)
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
})
it('returns noop when deployed version matches bundled version', async () => {
vi.stubEnv('DEV', false)
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ version: '0.1.0.57' })
}))
vi.stubGlobal('__APP_VERSION__', '0.1.0.57')
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
})
})
+281
View File
@@ -0,0 +1,281 @@
import { isNewerAppVersion, fetchDeployedVersion, getAppVersion } from './pwaVersion.js'
const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts'
const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts'
const HARD_RECOVERY_KEY = 'pwa_hard_recovery_ts'
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count'
const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts'
const STALE_RECOVERY_WINDOW_MS = 60_000
const RELOAD_DEBOUNCE_MS = 4_000
const COLD_START_UPDATE_DEBOUNCE_MS = 15_000
const HARD_RECOVERY_DEBOUNCE_MS = 30_000
export function recentlyAttemptedReload(now = Date.now()): boolean {
const last = Number(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0')
return now - last < RELOAD_DEBOUNCE_MS
}
export function markReloadAttempt(now = Date.now()): void {
sessionStorage.setItem(RELOAD_ATTEMPT_KEY, String(now))
}
function recentlyAttemptedColdStartUpdate(now = Date.now()): boolean {
const last = Number(sessionStorage.getItem(COLD_START_UPDATE_KEY) || '0')
return now - last < COLD_START_UPDATE_DEBOUNCE_MS
}
function markColdStartUpdateAttempt(now = Date.now()): void {
sessionStorage.setItem(COLD_START_UPDATE_KEY, String(now))
}
function recentlyAttemptedHardRecovery(now = Date.now()): boolean {
const last = Number(sessionStorage.getItem(HARD_RECOVERY_KEY) || '0')
return now - last < HARD_RECOVERY_DEBOUNCE_MS
}
function markHardRecoveryAttempt(now = Date.now()): void {
sessionStorage.setItem(HARD_RECOVERY_KEY, String(now))
}
function resetStaleRecoveryCount(): void {
sessionStorage.removeItem(STALE_RECOVERY_COUNT_KEY)
sessionStorage.removeItem(STALE_RECOVERY_LAST_KEY)
}
function incrementStaleRecoveryCount(now = Date.now()): number {
const last = Number(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY) || '0')
let current = Number(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY) || '0')
if (now - last > STALE_RECOVERY_WINDOW_MS) {
current = 0
}
const next = current + 1
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, String(next))
sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(now))
return next
}
function isStaleModuleLoadError(error: unknown): boolean {
const message =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: ''
return (
message.includes('Failed to fetch dynamically imported module') ||
message.includes('Importing a module script failed') ||
message.includes('error loading dynamically imported module') ||
message.includes('Loading chunk') ||
message.includes('ChunkLoadError') ||
message.includes('Unable to preload CSS')
)
}
export async function clearPwaCachesAndWorkers(): Promise<void> {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map((registration) => registration.unregister()))
}
if ('caches' in window) {
const keys = await caches.keys()
await Promise.all(keys.map((key) => caches.delete(key)))
}
}
/**
* Last-resort recovery when soft reloads cannot escape a stale precache.
* Equivalent to manually clearing site data / reinstalling the PWA.
*/
export async function forcePwaRecovery(): Promise<void> {
if (recentlyAttemptedHardRecovery()) return
markHardRecoveryAttempt()
markReloadAttempt()
resetStaleRecoveryCount()
await clearPwaCachesAndWorkers()
window.location.reload()
}
async function waitForWaitingWorker(
registration: ServiceWorkerRegistration,
timeoutMs: number
): Promise<ServiceWorker | null> {
if (registration.waiting) {
return registration.waiting
}
return new Promise((resolve) => {
const timeoutId = window.setTimeout(() => resolve(null), timeoutMs)
const inspectWorker = (worker: ServiceWorker | null) => {
if (!worker) return
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
window.clearTimeout(timeoutId)
resolve(worker)
return
}
worker.addEventListener(
'statechange',
() => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
window.clearTimeout(timeoutId)
resolve(worker)
}
},
{ once: true }
)
}
inspectWorker(registration.installing)
registration.addEventListener(
'updatefound',
() => {
inspectWorker(registration.installing)
},
{ once: true }
)
})
}
export async function triggerServiceWorkerUpdate(timeoutMs = 5_000): Promise<boolean> {
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
return false
}
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) return false
try {
await registration.update()
} catch {
return false
}
const waiting = await waitForWaitingWorker(registration, timeoutMs)
return waiting !== null
}
async function activateWaitingWorker(waiting: ServiceWorker): Promise<boolean> {
waiting.postMessage({ type: 'SKIP_WAITING' })
await new Promise<void>((resolve) => {
const timeoutId = window.setTimeout(resolve, 4_000)
navigator.serviceWorker.addEventListener(
'controllerchange',
() => {
window.clearTimeout(timeoutId)
resolve()
},
{ once: true }
)
})
return true
}
/**
* After missed deploys, a waiting SW may exist while the page still runs an old bundle.
* Apply the waiting worker once on cold start (one controlled reload) instead of hanging.
*/
export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
return false
}
if (recentlyAttemptedColdStartUpdate()) {
return false
}
const registration = await navigator.serviceWorker.getRegistration()
let waiting = registration?.waiting ?? null
if (!waiting && registration) {
await registration.update().catch(() => {})
waiting = await waitForWaitingWorker(registration, 4_000)
}
if (!waiting || !navigator.serviceWorker.controller) {
return false
}
markColdStartUpdateAttempt()
return activateWaitingWorker(waiting)
}
/**
* Compare deployed version.json with the bundled app version.
* When the server is ahead, try a soft SW takeover before hard recovery.
*/
export async function reconcileVersionOnStartup(): Promise<'reload' | 'recovered' | 'noop'> {
if (import.meta.env.DEV || !navigator.onLine) {
return 'noop'
}
const deployedVersion = await fetchDeployedVersion()
if (!deployedVersion || !isNewerAppVersion(deployedVersion, getAppVersion())) {
return 'noop'
}
const reconciled = await reconcileServiceWorkerOnStartup()
if (reconciled) {
return 'reload'
}
const updated = await triggerServiceWorkerUpdate()
if (updated) {
const registration = await navigator.serviceWorker.getRegistration()
const waiting = registration?.waiting
if (waiting) {
markColdStartUpdateAttempt()
await activateWaitingWorker(waiting)
return 'reload'
}
}
if (!recentlyAttemptedHardRecovery()) {
await forcePwaRecovery()
return 'recovered'
}
return 'noop'
}
export function installStaleAssetRecovery(): void {
if (import.meta.env.DEV) return
const recoverFromStaleAssets = () => {
if (recentlyAttemptedReload()) return
const attempts = incrementStaleRecoveryCount()
markReloadAttempt()
if (attempts >= 2) {
void forcePwaRecovery()
return
}
window.location.reload()
}
window.addEventListener('unhandledrejection', (event) => {
if (!isStaleModuleLoadError(event.reason)) return
event.preventDefault()
recoverFromStaleAssets()
})
window.addEventListener(
'error',
(event) => {
if (!isStaleModuleLoadError(event.message)) return
recoverFromStaleAssets()
},
true
)
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import {
compareAppVersions,
isNewerAppVersion,
parseAppVersion
} from './pwaVersion.js'
describe('pwaVersion', () => {
it('parses semantic build versions', () => {
expect(parseAppVersion('v0.1.0.57')).toEqual([0, 1, 0, 57])
})
it('compares build numbers numerically', () => {
expect(compareAppVersions('0.1.0.65', '0.1.0.57')).toBeGreaterThan(0)
expect(compareAppVersions('0.1.0.57', '0.1.0.65')).toBeLessThan(0)
expect(compareAppVersions('0.1.0.57', '0.1.0.57')).toBe(0)
})
it('detects newer deployed versions', () => {
expect(isNewerAppVersion('0.1.0.66', '0.1.0.57')).toBe(true)
expect(isNewerAppVersion('0.1.0.57', '0.1.0.57')).toBe(false)
})
})
+59
View File
@@ -0,0 +1,59 @@
const APP_VERSION =
typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0.0-dev'
export function getAppVersion(): string {
return APP_VERSION
}
export function parseAppVersion(version: string): number[] {
return version
.replace(/^v/i, '')
.split('.')
.map((part) => Number.parseInt(part, 10) || 0)
}
/** Positive when `a` is newer than `b`. */
export function compareAppVersions(a: string, b: string): number {
const partsA = parseAppVersion(a)
const partsB = parseAppVersion(b)
const length = Math.max(partsA.length, partsB.length)
for (let index = 0; index < length; index += 1) {
const diff = (partsA[index] ?? 0) - (partsB[index] ?? 0)
if (diff !== 0) return diff
}
return 0
}
export function isNewerAppVersion(serverVersion: string, clientVersion: string): boolean {
return compareAppVersions(serverVersion, clientVersion) > 0
}
export async function fetchDeployedVersion(timeoutMs = 4_000): Promise<string | null> {
if (!navigator.onLine) return null
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(`/version.json?_=${Date.now()}`, {
cache: 'no-store',
signal: controller.signal
})
if (!response.ok) return null
const payload = (await response.json()) as { version?: unknown }
return typeof payload.version === 'string' ? payload.version.trim() : null
} catch {
return null
} finally {
window.clearTimeout(timeoutId)
}
}
export async function isDeployedVersionNewer(): Promise<boolean> {
const deployedVersion = await fetchDeployedVersion()
if (!deployedVersion) return false
return isNewerAppVersion(deployedVersion, getAppVersion())
}
+22 -2
View File
@@ -10,6 +10,7 @@ import {
parseEventDistanceNm,
splitDistanceByPropulsion
} from '../utils/propulsionStats.js'
import { computeFuelPerMotorHour } from '../utils/fuelStats.js'
export type DistanceSource = 'gps' | 'events' | 'none'
@@ -27,6 +28,8 @@ export interface TravelDayStats {
sailDistanceNm: number
motorDistanceNm: number
unknownPropulsionNm: number
motorHours: number
fuelPerMotorHourL: number | null
hasGpsTrack: boolean
}
@@ -59,12 +62,15 @@ export interface StatsTotals {
sailDistanceNm: number
motorDistanceNm: number
unknownPropulsionNm: number
totalMotorHours: number
totalFuelL: number
totalFreshwaterL: number
avgDistancePerDayNm: number
avgMotorHoursPerDay: number
avgFuelPerDayL: number
avgFreshwaterPerDayL: number
fuelPerNmL: number | null
fuelPerMotorHourL: number | null
}
const TRACK_COLORS = [
@@ -102,6 +108,7 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0)
const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0)
const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 0)
const totalMotorHours = days.reduce((sum, d) => sum + d.motorHours, 0)
const totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 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)),
motorDistanceNm: Number(motorDistanceNm.toFixed(2)),
unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)),
totalMotorHours: Number(totalMotorHours.toFixed(1)),
totalFuelL: Number(totalFuelL.toFixed(1)),
totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)),
avgDistancePerDayNm:
travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0,
avgMotorHoursPerDay:
travelDayCount > 0 ? Number((totalMotorHours / travelDayCount).toFixed(1)) : 0,
avgFuelPerDayL:
travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0,
avgFreshwaterPerDayL:
@@ -123,7 +133,8 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
fuelPerNmL:
totalDistanceNm > 0 && totalFuelL > 0
? 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))
}
const fuelConsumptionL = Number(payload.fuel?.consumption) || 0
const motorHours = Number(payload.motorHours) || 0
days.push({
entryId: entry.payloadId,
logbookId,
@@ -189,11 +203,13 @@ async function loadTravelDaysForLogbook(
destination: payload.destination || '',
distanceNm,
distanceSource,
fuelConsumptionL: Number(payload.fuel?.consumption) || 0,
fuelConsumptionL,
freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0,
sailDistanceNm: propulsion.sailDistanceNm,
motorDistanceNm: propulsion.motorDistanceNm,
unknownPropulsionNm: propulsion.unknownPropulsionNm,
motorHours,
fuelPerMotorHourL: computeFuelPerMotorHour(fuelConsumptionL, motorHours),
hasGpsTrack
})
}
@@ -249,3 +265,7 @@ export function formatNm(value: number): string {
export function formatLiters(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
export function formatHours(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
+59 -5
View File
@@ -6,6 +6,7 @@ import { getLogbookAccess } from './logbookAccess.js'
const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>()
const pendingResync = new Set<string>()
let syncAllInFlight = 0
let isSyncing = false
const listeners = new Set<(syncing: boolean) => void>()
@@ -18,7 +19,8 @@ export function subscribeToSyncState(listener: (syncing: boolean) => void) {
}
}
function setSyncing(syncing: boolean) {
function recomputeSyncingState() {
const syncing = syncingLogbooks.size > 0 || syncAllInFlight > 0
if (isSyncing !== syncing) {
isSyncing = syncing
listeners.forEach((l) => l(isSyncing))
@@ -205,6 +207,54 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
return ok
}
type PulledServerPayload = {
yacht?: { updatedAt: string } | null
deviation?: { updatedAt: string } | null
crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }>
gpsTracks?: Array<{ entryId: string; updatedAt: string }>
}
/** Drop queue rows already reflected on the server (e.g. after direct API save). */
async function pruneAcknowledgedQueueItems(
logbookId: string,
server: PulledServerPayload
): Promise<void> {
const pending = await db.syncQueue.where({ logbookId }).toArray()
if (pending.length === 0) return
const serverTimes = new Map<string, string>()
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
const localLogbook = await db.logbooks.get(logbookId)
const staleIds: number[] = []
for (const item of pending) {
if (item.type === 'logbook') {
if (localLogbook?.isSynced === 1) {
if (item.id !== undefined) staleIds.push(item.id)
}
continue
}
const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}`
const serverUpdatedAt = serverTimes.get(key)
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
if (item.id !== undefined) staleIds.push(item.id)
}
}
if (staleIds.length > 0) {
await db.syncQueue.bulkDelete(staleIds)
}
}
// Pull updates from the server and apply last-write-wins
async function pullChanges(logbookId: string): Promise<boolean> {
if (!localStorage.getItem('active_userid')) return false
@@ -220,6 +270,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
}
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
// 1. Sync Yacht Payload
if (yacht) {
@@ -380,6 +431,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
}
}
await pruneAcknowledgedQueueItems(logbookId, serverSnapshot)
return true
} catch (error) {
console.error('Error during sync pull:', error)
@@ -400,7 +452,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
}
syncingLogbooks.add(logbookId)
setSyncing(true)
recomputeSyncingState()
try {
const pushed = await flushPushQueue(logbookId)
@@ -410,7 +462,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
return pushed && pulled && pushedAfterPull
} finally {
syncingLogbooks.delete(logbookId)
setSyncing(syncingLogbooks.size > 0)
recomputeSyncingState()
}
}
@@ -421,8 +473,9 @@ export async function syncAllLogbooks(): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) return
syncAllInFlight++
recomputeSyncingState()
try {
setSyncing(true)
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
const logbooks = await db.logbooks.toArray()
@@ -446,7 +499,8 @@ export async function syncAllLogbooks(): Promise<void> {
} catch (error) {
console.error('Error synchronizing all logbooks:', error)
} finally {
setSyncing(syncingLogbooks.size > 0)
syncAllInFlight = Math.max(0, syncAllInFlight - 1)
recomputeSyncingState()
}
}
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
getColorSchemePreference,
getOwmApiKey,
getOwmApiKeyForActiveUser,
getThemePreference,
setColorSchemePreference,
setOwmApiKey,
setThemePreference
} from './userPreferences.js'
const USER_ID = 'test-user-123'
describe('userPreferences', () => {
beforeEach(() => {
localStorage.clear()
})
it('migrates legacy theme and color scheme keys on first read', () => {
localStorage.setItem('active_userid', USER_ID)
localStorage.setItem('active_theme', 'material')
localStorage.setItem('active_color_scheme', 'dark')
expect(getThemePreference()).toBe('material')
expect(getColorSchemePreference()).toBe('dark')
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('material')
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
})
it('stores OWM key per user', () => {
setOwmApiKey(USER_ID, 'secret-key')
expect(getOwmApiKey(USER_ID)).toBe('secret-key')
setOwmApiKey(USER_ID, ' ')
expect(getOwmApiKey(USER_ID)).toBe('')
})
it('reads namespaced OWM key via active user id', () => {
setOwmApiKey(USER_ID, 'namespaced-only')
localStorage.setItem('active_userid', USER_ID)
localStorage.removeItem('owm_api_key')
expect(getOwmApiKeyForActiveUser()).toBe('namespaced-only')
expect(getOwmApiKey()).toBe('namespaced-only')
})
it('does not read namespaced OWM key without active user id', () => {
setOwmApiKey(USER_ID, 'namespaced-only')
localStorage.removeItem('active_userid')
localStorage.removeItem('owm_api_key')
expect(getOwmApiKeyForActiveUser()).toBe('')
expect(getOwmApiKey()).toBe('')
})
it('writes theme preferences to namespaced keys', () => {
setThemePreference(USER_ID, 'ocean')
setColorSchemePreference(USER_ID, 'light')
expect(getThemePreference(USER_ID)).toBe('ocean')
expect(getColorSchemePreference(USER_ID)).toBe('light')
})
})
+91
View File
@@ -0,0 +1,91 @@
const LEGACY_THEME = 'active_theme'
const LEGACY_COLOR_SCHEME = 'active_color_scheme'
const LEGACY_OWM_KEY = 'owm_api_key'
function themeKey(userId: string): string {
return `user_pref_theme_${userId}`
}
function colorSchemeKey(userId: string): string {
return `user_pref_color_scheme_${userId}`
}
function owmKey(userId: string): string {
return `user_pref_owm_api_key_${userId}`
}
export function getActiveUserId(): string | null {
return localStorage.getItem('active_userid')
}
function migrateLegacyPrefs(userId: string): void {
const pairs: Array<{ namespaced: string; legacy: string }> = [
{ namespaced: themeKey(userId), legacy: LEGACY_THEME },
{ namespaced: colorSchemeKey(userId), legacy: LEGACY_COLOR_SCHEME },
{ namespaced: owmKey(userId), legacy: LEGACY_OWM_KEY }
]
for (const { namespaced, legacy } of pairs) {
if (localStorage.getItem(namespaced) != null) continue
const value = localStorage.getItem(legacy)
if (value != null) {
localStorage.setItem(namespaced, value)
}
}
}
function resolveUserId(userId?: string | null): string | null {
const id = (userId?.trim() || getActiveUserId()?.trim()) || null
if (!id) return null
migrateLegacyPrefs(id)
return id
}
export function getThemePreference(userId?: string | null): string {
const id = resolveUserId(userId)
if (id) {
return localStorage.getItem(themeKey(id)) ?? localStorage.getItem(LEGACY_THEME) ?? 'auto'
}
return localStorage.getItem(LEGACY_THEME) ?? 'auto'
}
export function setThemePreference(userId: string, value: string): void {
migrateLegacyPrefs(userId)
localStorage.setItem(themeKey(userId), value)
}
export function getColorSchemePreference(userId?: string | null): string {
const id = resolveUserId(userId)
if (id) {
return localStorage.getItem(colorSchemeKey(id)) ?? localStorage.getItem(LEGACY_COLOR_SCHEME) ?? 'auto'
}
return localStorage.getItem(LEGACY_COLOR_SCHEME) ?? 'auto'
}
export function setColorSchemePreference(userId: string, value: string): void {
migrateLegacyPrefs(userId)
localStorage.setItem(colorSchemeKey(userId), value)
}
export function getOwmApiKey(userId?: string | null): string {
const id = resolveUserId(userId)
if (id) {
return localStorage.getItem(owmKey(id)) ?? localStorage.getItem(LEGACY_OWM_KEY) ?? ''
}
return localStorage.getItem(LEGACY_OWM_KEY) ?? ''
}
/** OWM key for the signed-in user (`active_userid`). Prefer this over a bare `getOwmApiKey()` call. */
export function getOwmApiKeyForActiveUser(): string {
return getOwmApiKey(getActiveUserId())
}
export function setOwmApiKey(userId: string, value: string): void {
migrateLegacyPrefs(userId)
const trimmed = value.trim()
if (trimmed) {
localStorage.setItem(owmKey(userId), trimmed)
} else {
localStorage.removeItem(owmKey(userId))
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { apiFetch } from './api.js'
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
@@ -26,7 +27,7 @@ export async function fetchOpenWeatherCurrent(params: {
throw new WeatherApiError('lat/lon or location query required')
}
const userKey = localStorage.getItem('owm_api_key')?.trim()
const userKey = getOwmApiKeyForActiveUser().trim()
const headers: Record<string, string> = {}
if (userKey) headers['X-OWM-Api-Key'] = userKey
+13
View File
@@ -1,10 +1,23 @@
/// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core'
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { NetworkOnly } from 'workbox-strategies'
declare let self: ServiceWorkerGlobalScope
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
clientsClaim()
// Always fetch the live deploy version, even under an older precache.
registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly())
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
void self.skipWaiting()
}
})
interface PushPayload {
title?: string
+16
View File
@@ -6,6 +6,7 @@
/* Fallback before JS hydrates (ocean · dark) */
html {
color-scheme: dark;
--app-theme-color: #0b0c10;
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
--app-text: #f1f5f9;
--app-text-heading: #f8fafc;
@@ -61,6 +62,7 @@ html {
/* ===== OCEAN · DARK (default) ===== */
html.scheme-dark.theme-ocean {
color-scheme: dark;
--app-theme-color: #0b0c10;
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
--app-text: #f1f5f9;
--app-text-heading: #f8fafc;
@@ -116,6 +118,7 @@ html.scheme-dark.theme-ocean {
/* ===== OCEAN · LIGHT ===== */
html.scheme-light.theme-ocean {
color-scheme: light;
--app-theme-color: #e2e8f0;
--app-body-bg: linear-gradient(165deg, #dbeafe 0%, #f8fafc 42%, #e2e8f0 100%);
--app-text: #1e293b;
--app-text-heading: #0f172a;
@@ -171,6 +174,7 @@ html.scheme-light.theme-ocean {
/* ===== MATERIAL · DARK ===== */
html.scheme-dark.theme-material {
color-scheme: dark;
--app-theme-color: #121212;
--app-body-bg: #121212;
--app-text: #f1f5f9;
--app-text-heading: #f8fafc;
@@ -226,6 +230,7 @@ html.scheme-dark.theme-material {
/* ===== MATERIAL · LIGHT ===== */
html.scheme-light.theme-material {
color-scheme: light;
--app-theme-color: #fafafa;
--app-body-bg: #fafafa;
--app-text: #212121;
--app-text-heading: #111827;
@@ -281,6 +286,7 @@ html.scheme-light.theme-material {
/* ===== CUPERTINO · DARK ===== */
html.scheme-dark.theme-cupertino {
color-scheme: dark;
--app-theme-color: #000000;
--app-body-bg: #000000;
--app-text: #ffffff;
--app-text-heading: #ffffff;
@@ -336,6 +342,7 @@ html.scheme-dark.theme-cupertino {
/* ===== CUPERTINO · LIGHT ===== */
html.scheme-light.theme-cupertino {
color-scheme: light;
--app-theme-color: #f2f2f7;
--app-body-bg: #f2f2f7;
--app-text: #1c1c1e;
--app-text-heading: #000000;
@@ -396,3 +403,12 @@ html.scheme-light.theme-cupertino {
html.scheme-light #root {
border-inline-color: var(--app-border-subtle);
}
/* Bridge legacy index.css tokens to appearance (avoids system color-scheme drift) */
html.scheme-light,
html.scheme-dark {
--text: var(--app-text);
--text-h: var(--app-text-heading);
--code-bg: var(--app-icon-btn-bg);
--border: var(--app-border-subtle);
}
+13 -2
View File
@@ -11,5 +11,16 @@ export interface PasskeySignature {
clientVerified: boolean
}
/** Legacy: PNG data URL oder getippter Name */
export type SignatureValue = string | PasskeySignature
/** Klassische Unterschrift mit Benutzer-Zuordnung (Crew) */
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)
})
})
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import {
buildLogEntryPayload,
hasUnsavedEventDraft,
isLogEventDraftEmpty,
normalizeLogEvent,
type LogEventPayload
} from './logEntryPayload.js'
const emptyDraft = (): LogEventPayload =>
normalizeLogEvent({ time: '12:34' })
const filledDraft = (): LogEventPayload =>
normalizeLogEvent({ time: '12:34', remarks: 'Wind dreht' })
describe('logEntryPayload event drafts', () => {
it('treats time-only draft as empty', () => {
expect(isLogEventDraftEmpty(emptyDraft())).toBe(true)
})
it('detects draft with content', () => {
expect(isLogEventDraftEmpty(filledDraft())).toBe(false)
})
it('does not flag empty open form as unsaved', () => {
expect(hasUnsavedEventDraft(emptyDraft(), null, [])).toBe(false)
})
it('flags new event draft with content as unsaved', () => {
expect(hasUnsavedEventDraft(filledDraft(), null, [])).toBe(true)
})
it('flags edited event when values differ', () => {
const events = [emptyDraft()]
const edited = filledDraft()
expect(hasUnsavedEventDraft(edited, 0, events)).toBe(true)
})
it('ignores edit mode when values match', () => {
const events = [filledDraft()]
expect(hasUnsavedEventDraft(filledDraft(), 0, events)).toBe(false)
})
})
describe('buildLogEntryPayload greywater', () => {
const base = {
date: '2026-05-31',
dayOfTravel: '1',
departure: 'Kiel',
destination: 'Laboe',
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
events: [] as LogEventPayload[]
}
it('includes greywater when level > 0', () => {
const payload = buildLogEntryPayload({ ...base, greywater: { level: 45 } })
expect(payload.greywater).toEqual({ level: 45 })
})
it('omits greywater when level is 0', () => {
const payload = buildLogEntryPayload({ ...base, greywater: { level: 0 } })
expect(payload.greywater).toBeUndefined()
})
})
+133 -1
View File
@@ -1,3 +1,8 @@
import {
normalizeCourseAngleString,
normalizeWindDirectionString
} from './courseAngle.js'
export interface LogEventPayload {
time: string
mgk: string
@@ -17,6 +22,121 @@ export interface LogEventPayload {
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])
}
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time')
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */
export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
return LOG_EVENT_CONTENT_FIELDS.every((key) => !event[key]?.trim())
}
/** Whether the event form holds unsaved changes worth merging on page save. */
export function hasUnsavedEventDraft(
draft: LogEventPayload,
editingEventIndex: number | null,
events: LogEventPayload[]
): boolean {
if (!isValidTimeHHMM(draft.time)) return false
if (editingEventIndex !== null) {
const original = events[editingEventIndex]
return original ? !logEventsEqual(draft, original) : false
}
return !isLogEventDraftEmpty(draft)
}
/** 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 {
date: string
dayOfTravel: string
@@ -24,9 +144,11 @@ export interface LogEntryPayloadInput {
destination: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
greywater?: { level: number }
trackDistanceNm?: number
trackSpeedMaxKn?: number
trackSpeedAvgKn?: number
motorHours?: number
events: LogEventPayload[]
}
@@ -38,12 +160,22 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
destination: input.destination.trim(),
freshwater: { ...input.freshwater },
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.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn
if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn
if (input.motorHours !== undefined && input.motorHours > 0) {
payload.motorHours = Number(input.motorHours.toFixed(2))
}
if (input.greywater !== undefined) {
const level = Number(input.greywater.level) || 0
if (level > 0) {
payload.greywater = { level: Number(level.toFixed(1)) }
}
}
return payload
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
carryOverFromPreviousDay,
getClosingGreywaterLevel,
hasCarryOverFromPreviousDay
} from './logEntryTankLevels.js'
describe('logEntryTankLevels greywater carry-over', () => {
it('returns previous greywater level as starting value', () => {
const carryOver = carryOverFromPreviousDay({
destination: 'Oslo',
freshwater: { morning: 100, refilled: 0, evening: 80, consumption: 20 },
fuel: { morning: 200, refilled: 0, evening: 150, consumption: 50 },
greywater: { level: 42 }
})
expect(carryOver.greywaterLevel).toBe(42)
expect(carryOver.freshwater.morning).toBe(80)
expect(carryOver.fuel.morning).toBe(150)
expect(carryOver.departure).toBe('Oslo')
})
it('defaults greywater to 0 when previous day has none', () => {
expect(carryOverFromPreviousDay(null).greywaterLevel).toBe(0)
expect(getClosingGreywaterLevel(undefined)).toBe(0)
})
it('treats greywater level as carry-over candidate', () => {
expect(
hasCarryOverFromPreviousDay({
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
greywaterLevel: 15,
departure: ''
})
).toBe(true)
})
})
+14 -2
View File
@@ -41,12 +41,14 @@ export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
export interface LogEntryTankSource {
freshwater?: Partial<TankLevels>
fuel?: Partial<TankLevels>
greywater?: { level?: number }
destination?: string
}
export interface CarryOverFromPreviousDay {
freshwater: TankLevels
fuel: TankLevels
greywaterLevel: number
departure: string
}
@@ -59,6 +61,10 @@ export function formatTankLiters(liters: number): string {
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
}
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
return Number(greywater?.level) || 0
}
export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankSource | null): { freshwater: TankLevels; fuel: TankLevels } {
if (!previousEntry) {
return { freshwater: emptyTankLevels(), fuel: emptyTankLevels() }
@@ -73,10 +79,16 @@ export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankS
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
const departure = previousEntry?.destination?.trim() || ''
const greywaterLevel = getClosingGreywaterLevel(previousEntry?.greywater)
return { freshwater, fuel, departure }
return { freshwater, fuel, greywaterLevel, departure }
}
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
return carryOver.freshwater.morning > 0 || carryOver.fuel.morning > 0 || carryOver.departure.length > 0
return (
carryOver.freshwater.morning > 0 ||
carryOver.fuel.morning > 0 ||
carryOver.greywaterLevel > 0 ||
carryOver.departure.length > 0
)
}
+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 type { PasskeySignature, SignatureValue } from '../types/signatures.js'
import type { ClassicSignature, PasskeySignature, SignatureValue } from '../types/signatures.js'
export type SkipperSignStatus = 'none' | 'valid' | 'invalid'
export interface SignatureAttribution {
username: string
signedAt: string
}
export function isSignatureImage(value: string | undefined | null): boolean {
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 {
if (value === null || value === undefined || value === '') return undefined
if (isPasskeySignature(value)) return value
if (isClassicSignature(value)) return value
if (typeof value === 'string') return value
return undefined
}
@@ -47,6 +95,7 @@ export async function getSkipperSignStatus(
export interface SignatureExportLabels {
imagePlaceholder: string
passkeyLabel: (username: string, signedAt: string) => string
attributionLabel: (username: string, signedAt: string) => string
}
export function formatSignatureForExport(
@@ -57,15 +106,19 @@ export function formatSignatureForExport(
if (isPasskeySignature(value)) {
return labels.passkeyLabel(value.username, value.signedAt)
}
if (isClassicSignature(value)) {
return labels.attributionLabel(value.username, value.signedAt)
}
if (isSignatureImage(value)) return labels.imagePlaceholder
return value
}
export function serializeSignature(value: SignatureValue | '' | undefined): SignatureValue | undefined {
if (!value) return undefined
if (isPasskeySignature(value)) return value
if (isSignatureImage(value)) return value
const trimmed = value.trim()
if (isPasskeySignature(value) || isClassicSignature(value)) return value
const payload = typeof value === 'string' ? value : getSignaturePayload(value)
if (isSignatureImage(payload)) return payload
const trimmed = payload.trim()
return trimmed || undefined
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
clampTankLiters,
computeEveningTankMaxLiters,
computeRefilledTankMaxLiters,
extractTankCapacitiesFromYacht,
formatTankLitersForInput,
parseOptionalTankLiters,
tankCapacityInputFromStored
} from './tankCapacity.js'
describe('tankCapacity', () => {
it('parses optional liters with comma decimal', () => {
expect(parseOptionalTankLiters('200')).toBe(200)
expect(parseOptionalTankLiters('12,5')).toBe(12.5)
expect(parseOptionalTankLiters('')).toBeUndefined()
})
it('rejects negative or invalid liters', () => {
expect(() => parseOptionalTankLiters('-1')).toThrow('invalid_tank_liters')
expect(() => parseOptionalTankLiters('abc')).toThrow('invalid_tank_liters')
})
it('extracts capacities from yacht payload', () => {
expect(
extractTankCapacitiesFromYacht({
freshwaterCapacityL: 300,
fuelCapacityL: 120,
greywaterCapacityL: 80
})
).toEqual({
freshwaterCapacityL: 300,
fuelCapacityL: 120,
greywaterCapacityL: 80
})
expect(extractTankCapacitiesFromYacht({ name: 'Test' })).toEqual({})
})
it('formats stored capacity for input', () => {
expect(tankCapacityInputFromStored(150)).toBe('150')
expect(formatTankLitersForInput(12.5)).toBe('12.5')
})
it('clamps liters to max when set', () => {
expect(clampTankLiters(250, 200)).toBe(200)
expect(clampTankLiters(-5, 200)).toBe(0)
expect(clampTankLiters(50)).toBe(50)
})
it('computes refilled max as capacity minus morning', () => {
expect(computeRefilledTankMaxLiters('10', 60)).toBe(50)
expect(computeRefilledTankMaxLiters('60', 60)).toBeUndefined()
expect(computeRefilledTankMaxLiters('10', undefined)).toBeUndefined()
})
it('computes evening max as morning plus refilled capped by capacity', () => {
expect(computeEveningTankMaxLiters('10', '20', 60)).toBe(30)
expect(computeEveningTankMaxLiters('40', '40', 60)).toBe(60)
expect(computeEveningTankMaxLiters('10', '20')).toBe(30)
expect(computeEveningTankMaxLiters('0', '0', 60)).toBeUndefined()
})
})
+101
View File
@@ -0,0 +1,101 @@
import { formatTankLiters } from './logEntryTankLevels.js'
export interface VesselTankCapacities {
freshwaterCapacityL?: number
fuelCapacityL?: number
greywaterCapacityL?: number
}
export function parseOptionalTankLiters(input: string): number | undefined {
const trimmed = input.trim().replace(',', '.')
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error('invalid_tank_liters')
}
return parsed
}
export function formatTankLitersForInput(liters: number): string {
return formatTankLiters(liters)
}
function capacityFromStored(value: unknown): number | undefined {
if (value == null || value === '') return undefined
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
if (typeof value === 'string') {
const trimmed = value.trim().replace(',', '.')
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (Number.isFinite(parsed) && parsed >= 0) return parsed
}
return undefined
}
export function tankCapacityInputFromStored(value: unknown): string {
const n = capacityFromStored(value)
return n != null ? formatTankLitersForInput(n) : ''
}
export function extractTankCapacitiesFromYacht(decrypted: unknown): VesselTankCapacities {
if (!decrypted || typeof decrypted !== 'object') return {}
const y = decrypted as Record<string, unknown>
const capacities: VesselTankCapacities = {}
const fw = capacityFromStored(y.freshwaterCapacityL)
const fuel = capacityFromStored(y.fuelCapacityL)
const gw = capacityFromStored(y.greywaterCapacityL)
if (fw != null) capacities.freshwaterCapacityL = fw
if (fuel != null) capacities.fuelCapacityL = fuel
if (gw != null) capacities.greywaterCapacityL = gw
return capacities
}
/** Parse a liter amount from form state (string). */
export function parseTankLitersFromInput(input: string): number {
const trimmed = input.trim().replace(',', '.')
if (!trimmed) return 0
const parsed = Number(trimmed)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
}
/**
* Max for refilled amount: remaining capacity after morning level.
* Returns undefined when no positive max (no slider).
*/
export function computeRefilledTankMaxLiters(
morningInput: string,
tankCapacityL?: number
): number | undefined {
if (tankCapacityL == null || tankCapacityL <= 0) return undefined
const remaining = tankCapacityL - parseTankLitersFromInput(morningInput)
if (remaining <= 0) return undefined
return remaining
}
/**
* Max for evening fill level: morning + refilled, capped by tank capacity when known.
* Returns undefined when no positive max (no slider).
*/
export function computeEveningTankMaxLiters(
morningInput: string,
refilledInput: string,
tankCapacityL?: number
): number | undefined {
const sum = parseTankLitersFromInput(morningInput) + parseTankLitersFromInput(refilledInput)
if (sum <= 0) return undefined
if (tankCapacityL != null && tankCapacityL > 0) {
return Math.min(tankCapacityL, sum)
}
return sum
}
/** Clamp numeric liter value to [0, max] when max is known. */
export function clampTankLiters(value: number, maxLiters?: number): number {
const clamped = Math.max(0, value)
if (maxLiters != null && maxLiters > 0) {
return Math.min(clamped, maxLiters)
}
return clamped
}
+2 -1
View File
@@ -21,5 +21,6 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+27 -2
View File
@@ -1,9 +1,11 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
import { readFileSync } from 'node:fs'
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Plugin } from 'vite'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -18,8 +20,25 @@ function readAppVersion(): string {
}
}
function versionJsonPlugin(version: string): Plugin {
return {
name: 'version-json',
writeBundle(options) {
const outDir = options.dir ?? resolve(__dirname, 'dist')
writeFileSync(
resolve(outDir, 'version.json'),
`${JSON.stringify({ version }, null, 2)}\n`
)
}
}
}
// https://vite.dev/config/
export default defineConfig({
test: {
environment: 'happy-dom',
include: ['src/**/*.test.ts']
},
define: {
__APP_VERSION__: JSON.stringify(readAppVersion())
},
@@ -37,11 +56,15 @@ export default defineConfig({
},
plugins: [
react(),
versionJsonPlugin(readAppVersion()),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
registerType: 'prompt',
devOptions: {
enabled: false
},
includeAssets: ['favicon.ico', 'logo.png'],
injectManifest: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}']
@@ -49,7 +72,9 @@ export default defineConfig({
manifest: {
name: 'Kapteins 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',
background_color: '#0f172a',
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

+116 -30
View File
@@ -28,9 +28,11 @@
.page {
width: 210mm;
height: 297mm;
padding: 14mm 16mm 12mm;
max-height: 297mm;
padding: 12mm 15mm 10mm;
display: flex;
flex-direction: column;
gap: 5mm;
background:
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%),
@@ -52,20 +54,20 @@
display: flex;
align-items: center;
gap: 5mm;
margin-bottom: 6mm;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.logo {
width: 14mm;
height: 14mm;
width: 16mm;
height: 16mm;
flex-shrink: 0;
object-fit: contain;
}
.title-block h1 {
font-size: 22pt;
font-size: 23pt;
font-weight: 700;
letter-spacing: -0.02em;
color: #f8fafc;
@@ -73,7 +75,7 @@
}
.title-block p {
font-size: 10.5pt;
font-size: 12pt;
color: #94a3b8;
margin-top: 1.5mm;
}
@@ -83,19 +85,19 @@
align-self: flex-start;
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b;
font-size: 9pt;
font-size: 11pt;
font-weight: 800;
letter-spacing: 0.12em;
padding: 2mm 4mm;
padding: 2.5mm 4.5mm;
border-radius: 2mm;
text-transform: uppercase;
}
.intro {
font-size: 10.5pt;
line-height: 1.55;
font-size: 12pt;
line-height: 1.5;
color: #cbd5e1;
margin-bottom: 6mm;
flex-shrink: 0;
max-width: 95%;
position: relative;
z-index: 1;
@@ -105,11 +107,48 @@
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 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3mm 6mm;
margin-bottom: 6mm;
gap: 1.8mm 6mm;
flex-shrink: 0;
position: relative;
z-index: 1;
}
@@ -118,8 +157,8 @@
display: flex;
gap: 2.5mm;
align-items: flex-start;
font-size: 9.5pt;
line-height: 1.4;
font-size: 10.5pt;
line-height: 1.28;
color: #e2e8f0;
}
@@ -130,26 +169,53 @@
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 {
background: rgba(30, 41, 59, 0.85);
border: 1px solid rgba(251, 191, 36, 0.35);
border-left: 3px solid #fbbf24;
border-radius: 3mm;
padding: 5mm 6mm;
margin-bottom: 6mm;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.beta-box h2 {
font-size: 11pt;
font-size: 12.5pt;
color: #fbbf24;
margin-bottom: 2mm;
font-weight: 700;
}
.beta-box p {
font-size: 9.5pt;
font-size: 10.5pt;
line-height: 1.5;
color: #cbd5e1;
}
@@ -157,12 +223,12 @@
.cta {
display: flex;
align-items: center;
gap: 8mm;
gap: 7mm;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 4mm;
padding: 5mm 6mm;
margin-bottom: auto;
flex-shrink: 0;
position: relative;
z-index: 1;
}
@@ -183,16 +249,16 @@
}
.cta-text h3 {
font-size: 13pt;
font-size: 14.5pt;
color: #38bdf8;
font-weight: 700;
margin-bottom: 2mm;
}
.cta-text p {
font-size: 9pt;
font-size: 11pt;
color: #94a3b8;
line-height: 1.45;
line-height: 1.5;
}
.tags {
@@ -203,7 +269,7 @@
}
.tag {
font-size: 7.5pt;
font-size: 9.5pt;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -216,8 +282,9 @@
footer {
border-top: 1px solid rgba(148, 163, 184, 0.15);
padding-top: 3mm;
margin-top: 5mm;
font-size: 7.5pt;
margin-top: auto;
flex-shrink: 0;
font-size: 9.5pt;
line-height: 1.5;
color: #64748b;
position: relative;
@@ -248,20 +315,39 @@
</p>
<section class="features" aria-label="Funktionen">
<div class="feature"><span class="feature-icon"></span><span>Reisetage im nautischen Logbuch-Format (Hafen, Wetter, Besegelung, Crew, Tankstände, etc.)</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 — läuft auf jedem Smartphone &amp; Tablet</span></div>
<div class="feature"><span class="feature-icon"></span><span>Passkey-Anmeldung &amp; Ende-zu-Ende Verschlüsselung</span></div>
<div class="feature"><span class="feature-icon"></span><span>GPS-Track Upload (GPX/KML), Karte &amp; Streckenstatistik</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>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-Avatarbilder 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>PDF- &amp; CSV-Export</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>Deutsch &amp; Englisch</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>3 Themes, jeweils mit heller und dunkler Variante</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 class="beta-box">
<h2>Beta-Phase — Dein Feedback zählt</h2>
<p>
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)
+12
View File
@@ -40,12 +40,23 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.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
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
- **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)
@@ -57,6 +68,7 @@ Empfohlene Goal-Ketten für Auswertung:
4. **Öffentliche Freigabe:** Logbook Shared → Public Link Opened
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
+1 -1
View File
@@ -59,7 +59,7 @@ bump_patch_version() {
}
ensure_clean_git_tree() {
if git diff-index --quiet HEAD -- && [ -z "$(git status --porcelain)" ]; then
if [ -z "$(git status --porcelain)" ]; then
return 0
fi
+11
View File
@@ -22,6 +22,7 @@ model User {
collaborations Collaboration[]
pushSubscriptions PushSubscription[]
notificationPrefs UserNotificationPrefs?
appearancePrefs UserAppearancePrefs?
}
model PushSubscription {
@@ -48,10 +49,20 @@ model UserNotificationPrefs {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model UserAppearancePrefs {
userId String @id
theme String @default("auto")
colorScheme String @default("auto")
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Credential {
id String @id @default(uuid())
userId String
credentialId String @unique
label String?
publicKey Bytes
counter BigInt
transports String[] // WebAuthn transports list
+331
View File
@@ -22,8 +22,33 @@ const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173'
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>()
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)
}
const VALID_THEMES = new Set(['auto', 'ocean', 'material', 'cupertino'])
const VALID_COLOR_SCHEMES = new Set(['auto', 'light', 'dark'])
function parseThemePreference(value: unknown): string | null {
return typeof value === 'string' && VALID_THEMES.has(value) ? value : null
}
function parseColorSchemePreference(value: unknown): string | null {
return typeof value === 'string' && VALID_COLOR_SCHEMES.has(value) ? value : null
}
router.post('/register-options', async (req, res) => {
try {
const { username } = req.body
@@ -381,4 +406,310 @@ 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('/appearance-prefs', requireUser, async (req: any, res) => {
try {
const prefs = await prisma.userAppearancePrefs.findUnique({
where: { userId: req.userId }
})
return res.json({
theme: prefs?.theme ?? 'auto',
colorScheme: prefs?.colorScheme ?? 'auto',
persisted: prefs != null
})
} catch (error: any) {
console.error('Error reading appearance prefs:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.put('/appearance-prefs', requireUser, async (req: any, res) => {
try {
const theme = parseThemePreference(req.body?.theme)
const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
if (!theme || !colorScheme) {
return res.status(400).json({ error: 'Invalid theme or colorScheme' })
}
const prefs = await prisma.userAppearancePrefs.upsert({
where: { userId: req.userId },
create: {
userId: req.userId,
theme,
colorScheme,
updatedAt: new Date()
},
update: {
theme,
colorScheme,
updatedAt: new Date()
}
})
return res.json({
theme: prefs.theme,
colorScheme: prefs.colorScheme,
persisted: true
})
} catch (error: any) {
console.error('Error updating appearance prefs:', 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
+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
+36 -13
View File
@@ -57,6 +57,13 @@ function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: stri
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(
logbookId: string,
role: 'skipper' | 'crew',
@@ -79,7 +86,16 @@ async function getAllowCredentialsForRole(
})
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({
where: { userId: { in: userIds } }
@@ -99,14 +115,7 @@ async function isAuthorizedSigner(
role: 'skipper' | 'crew'
): Promise<boolean> {
if (role === 'skipper') {
// Skipper signing: owner or WRITE collaborator (design §2.1), using their own passkey.
if (signerUserId === ownerUserId) return true
const collaboration = await prisma.collaboration.findUnique({
where: {
logbookId_userId: { logbookId, userId: signerUserId }
}
})
return collaboration?.role === 'WRITE'
return signerUserId === ownerUserId
}
const collaboration = await prisma.collaboration.findUnique({
@@ -114,7 +123,13 @@ async function isAuthorizedSigner(
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) => {
@@ -138,6 +153,16 @@ router.post('/options', async (req: any, res) => {
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(
logbookId,
role,
@@ -146,9 +171,7 @@ router.post('/options', async (req: any, res) => {
if (allowCredentials.length === 0) {
return res.status(400).json({
error: role === 'crew'
? 'No write collaborators with passkeys found'
: 'No passkey credentials found for signer'
error: '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
// Exception: If action is create logbook, the logbook might not exist yet,
// 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({
where: { id: logbookId }
})
@@ -69,9 +69,9 @@ router.post('/push', async (req: any, res) => {
},
update: {
encryptedTitle: parsed.encryptedTitle,
encryptedKey: parsed.encryptedKey || null,
iv: parsed.iv || null,
tag: parsed.tag || null,
...(parsed.encryptedKey !== undefined ? { encryptedKey: parsed.encryptedKey } : {}),
...(parsed.iv !== undefined ? { iv: parsed.iv } : {}),
...(parsed.tag !== undefined ? { tag: parsed.tag } : {}),
updatedAt: itemUpdatedAt
}
})
@@ -121,6 +121,17 @@ router.post('/push', async (req: any, res) => {
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 (type === 'yacht') {
await prisma.yachtPayload.deleteMany({ where: { logbookId } })