Compare commits

...

26 Commits

Author SHA1 Message Date
elpatron c67c1425df chore: release v0.1.0.41 2026-05-30 19:32:37 +02:00
elpatron d231a7fb40 feat(logs): Maschinenstunden pro Reisetag und Verbrauch pro Stunde
Maschinenstunden sind im Journal erfassbar; der Kraftstoffverbrauch pro Maschinenstunde wird aus Tagesverbrauch und Maschinenstunden berechnet und in Journal sowie Statistik als Read-only angezeigt.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:31:52 +02:00
elpatron 90ffff0da6 fix(beta-flyer): update feature descriptions for clarity and accuracy
Revised the descriptions of offline capabilities and encryption methods in the beta flyer to enhance clarity. The PWA is now described as functioning on any smartphone and tablet, and the encryption method is specified as end-to-end.
2026-05-30 14:23:58 +02:00
36 changed files with 1335 additions and 277 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.38 0.1.0.42
+341 -1
View File
@@ -63,6 +63,16 @@ body {
margin-bottom: 15px; margin-bottom: 15px;
} }
.auth-brand-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 14px;
}
.auth-brand-title-row h1,
.auth-brand h1 { .auth-brand h1 {
font-size: 32px; font-size: 32px;
font-weight: 700; font-weight: 700;
@@ -71,7 +81,7 @@ body {
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
margin: 0 0 14px 0; margin: 0;
line-height: 1.25; line-height: 1.25;
letter-spacing: -0.5px; letter-spacing: -0.5px;
} }
@@ -895,6 +905,36 @@ html.scheme-dark .themed-select-option.is-selected {
color: var(--app-text-heading); color: var(--app-text-heading);
} }
.section-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.copy-link-row {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
min-width: 0;
}
.copy-link-row .input-text {
flex: 1;
min-width: 0;
}
.form-actions--start {
justify-content: flex-start;
}
.table-responsive {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.btn-refresh { .btn-refresh {
background: none; background: none;
border: none; border: none;
@@ -1635,6 +1675,224 @@ html.scheme-dark .themed-select-option.is-selected {
.hide-mobile { .hide-mobile {
display: none !important; display: none !important;
} }
.dashboard-header,
.app-header {
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
}
.app-header-left {
flex: 1 1 100%;
min-width: 0;
align-items: flex-start;
gap: 10px;
}
.app-title-area {
min-width: 0;
flex: 1;
}
.app-title-area h2 {
font-size: 17px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.app-title-row {
gap: 6px;
}
.app-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-brand {
flex: 1 1 auto;
min-width: 0;
}
.header-brand h1 {
font-size: 20px;
}
.header-actions {
flex: 1 1 100%;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.conn-status > span:not(.pulse-dot) {
display: none;
}
.skipper-badge__name {
display: none;
}
.btn-back {
padding: 8px 10px;
flex-shrink: 0;
}
.section-title-bar {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.section-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.section-toolbar .btn {
flex: 1 1 auto;
min-width: 0;
}
.section-toolbar .btn.primary {
flex: 1 1 100%;
}
.section-title-left {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.section-title-left .form-header h2 {
font-size: 16px;
white-space: normal;
word-break: break-word;
}
.logbooks-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.logbook-card {
flex-wrap: wrap;
padding: 16px;
gap: 12px;
}
.card-meta {
flex-wrap: wrap;
}
.card-info h3 {
white-space: normal;
word-break: break-word;
}
.editor-header {
flex-wrap: wrap;
gap: 10px;
}
.crew-grid {
grid-template-columns: 1fr;
}
.copy-link-row {
flex-direction: column;
align-items: stretch;
}
.copy-link-row .btn {
width: 100%;
}
.form-actions--start {
flex-direction: column;
align-items: stretch;
}
.form-actions--start .btn {
width: 100%;
}
.table-responsive {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-responsive table {
min-width: 480px;
}
.custom-dialog-overlay {
padding: max(16px, env(safe-area-inset-left)) max(16px, env(safe-area-inset-right));
align-items: flex-end;
}
.custom-dialog-card {
width: 100%;
max-width: none;
padding: 22px 18px;
margin-bottom: env(safe-area-inset-bottom, 0px);
}
.custom-dialog-actions {
flex-direction: column-reverse;
gap: 10px;
}
.custom-dialog-actions .btn {
width: 100%;
margin: 0 !important;
}
.disclaimer-modal-overlay {
padding: max(12px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-right));
align-items: flex-end;
}
.disclaimer-modal-panel,
.registration-disclaimer--modal {
width: 100%;
max-width: none;
}
.auth-card {
padding: 28px 20px;
max-width: calc(100vw - 24px);
}
.app-layout,
.dashboard-container {
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
}
.track-info-left {
flex-wrap: wrap;
}
.track-actions {
width: 100%;
}
.track-actions .btn {
flex: 1 1 calc(50% - 4px);
justify-content: center;
}
#openseamap-container,
.track-map-container {
height: min(360px, 45svh);
}
} }
/* ========================================== */ /* ========================================== */
@@ -2257,6 +2515,12 @@ html.theme-cupertino .events-scroll-container {
color: #94a3b8; color: #94a3b8;
} }
.track-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.track-error-msg { .track-error-msg {
color: #ef4444; color: #ef4444;
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
@@ -2396,6 +2660,13 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-text-muted); color: var(--app-text-muted);
} }
.stats-section-subtitle {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--app-text-primary);
}
.stats-route-chain { .stats-route-chain {
margin: 0; margin: 0;
font-size: 15px; font-size: 15px;
@@ -2498,6 +2769,14 @@ html.theme-cupertino .events-scroll-container {
background: linear-gradient(180deg, #38bdf8, #0284c7); background: linear-gradient(180deg, #38bdf8, #0284c7);
} }
.stats-bar--motor-hours {
background: linear-gradient(180deg, #a78bfa, #7c3aed);
}
.stats-bar--fuel-per-hour {
background: linear-gradient(180deg, #fb923c, #ea580c);
}
.stats-bar-label { .stats-bar-label {
margin-top: 8px; margin-top: 8px;
font-size: 11px; font-size: 11px;
@@ -3191,6 +3470,28 @@ html.theme-cupertino .events-scroll-container {
border: 1px solid rgba(251, 191, 36, 0.25); border: 1px solid rgba(251, 191, 36, 0.25);
} }
.beta-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--app-accent-light);
background: var(--app-accent-bg);
border: 1px solid var(--app-accent-focus-ring);
flex-shrink: 0;
}
.header-brand-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.role-badge { .role-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -3320,7 +3621,9 @@ body.app-tour-active .app-tour-target-active {
.app-tour-tooltip { .app-tour-tooltip {
position: fixed; position: fixed;
z-index: 10002; z-index: 10002;
box-sizing: border-box;
width: min(420px, calc(100vw - 32px)); width: min(420px, calc(100vw - 32px));
max-width: calc(100vw - 32px);
padding: 20px 20px 16px; padding: 20px 20px 16px;
border-radius: 16px; border-radius: 16px;
background: #1e293b; background: #1e293b;
@@ -3329,10 +3632,19 @@ body.app-tour-active .app-tour-target-active {
pointer-events: auto; pointer-events: auto;
} }
.app-tour-tooltip:not(.centered) {
left: max(16px, env(safe-area-inset-left, 0px));
right: max(16px, env(safe-area-inset-right, 0px));
width: auto;
max-width: none;
}
.app-tour-tooltip.centered { .app-tour-tooltip.centered {
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: min(420px, calc(100vw - 32px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)));
max-width: calc(100vw - 32px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
} }
.app-tour-close { .app-tour-close {
@@ -3409,6 +3721,34 @@ body.app-tour-active .app-tour-target-active {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
flex-shrink: 0;
}
@media (max-width: 520px) {
.app-tour-tooltip {
padding: 18px 16px 14px;
}
.app-tour-actions {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.app-tour-nav {
margin-left: 0;
width: 100%;
}
.app-tour-nav-btn {
flex: 1;
justify-content: center;
min-width: 0;
}
}
body.app-tour-active .pwa-install-banner {
display: none !important;
} }
body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour { body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour {
+32 -12
View File
@@ -13,6 +13,7 @@ import SettingsForm from './components/SettingsForm.tsx'
import InvitationAcceptance from './components/InvitationAcceptance.tsx' import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import AppTourOverlay from './components/AppTourOverlay.tsx' import AppTourOverlay from './components/AppTourOverlay.tsx'
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx' import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js' import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
import { import {
@@ -28,6 +29,7 @@ import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx' import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
import AppFooter from './components/AppFooter.tsx' import AppFooter from './components/AppFooter.tsx'
import LogbookRoleBadge from './components/LogbookRoleBadge.tsx' import LogbookRoleBadge from './components/LogbookRoleBadge.tsx'
import BetaBadge from './components/BetaBadge.tsx'
import { db } from './services/db.js' import { db } from './services/db.js'
import { getLogbookAccess } from './services/logbookAccess.js' import { getLogbookAccess } from './services/logbookAccess.js'
import type { LogbookAccessRole } from './services/logbook.js' import type { LogbookAccessRole } from './services/logbook.js'
@@ -47,6 +49,7 @@ const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
function App() { function App() {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { confirmLeave } = useUnsavedChangesContext()
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour() const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false)
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null) const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
@@ -346,7 +349,14 @@ function App() {
consumePendingPushLogbook() consumePendingPushLogbook()
} }
const handleLogout = () => { const handleTabChange = async (tab: AppTab) => {
if (tab === activeTab) return
if (!(await confirmLeave())) return
setActiveTab(tab)
}
const handleLogout = async () => {
if (!(await confirmLeave())) return
void logoutUser() void logoutUser()
setIsAuthenticated(false) setIsAuthenticated(false)
setActiveLogbookId(null) setActiveLogbookId(null)
@@ -357,7 +367,8 @@ function App() {
localStorage.removeItem('active_logbook_title') localStorage.removeItem('active_logbook_title')
} }
const handleBackToDashboard = () => { const handleBackToDashboard = async () => {
if (!(await confirmLeave())) return
setActiveLogbookId(null) setActiveLogbookId(null)
setActiveLogbookTitle(null) setActiveLogbookTitle(null)
setTourSelectedEntryId(null) setTourSelectedEntryId(null)
@@ -420,10 +431,12 @@ function App() {
) )
} }
const pwaInstallBanner = <PwaInstallPrompt variant="banner" /> const pwaInstallBanner = !isActive ? <PwaInstallPrompt variant="banner" /> : null
const logbookReadOnly = const logbookReadOnly =
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ' activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
if (!activeLogbookId) { if (!activeLogbookId) {
return ( return (
@@ -445,13 +458,14 @@ function App() {
{/* Active Logbook Header */} {/* Active Logbook Header */}
<header className="app-header"> <header className="app-header">
<div className="app-header-left"> <div className="app-header-left">
<button className="btn-back" onClick={handleBackToDashboard}> <button className="btn-back" onClick={handleBackToDashboard} title={t('nav.dashboard')}>
<ChevronLeft size={16} /> <ChevronLeft size={16} />
{t('nav.dashboard')} <span className="hide-mobile">{t('nav.dashboard')}</span>
</button> </button>
<div className="app-title-area"> <div className="app-title-area">
<div className="app-title-row"> <div className="app-title-row">
<h2>{activeLogbookTitle}</h2> <h2>{activeLogbookTitle}</h2>
<BetaBadge />
{activeAccessRole && activeAccessRole !== 'OWNER' && ( {activeAccessRole && activeAccessRole !== 'OWNER' && (
<LogbookRoleBadge role={activeAccessRole} /> <LogbookRoleBadge role={activeAccessRole} />
)} )}
@@ -503,7 +517,7 @@ function App() {
<aside className="app-sidebar"> <aside className="app-sidebar">
<button <button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`} className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')} onClick={() => void handleTabChange('logs')}
data-tour="nav-logs" data-tour="nav-logs"
> >
<FileText size={18} /> <FileText size={18} />
@@ -512,7 +526,7 @@ function App() {
<button <button
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`} className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => setActiveTab('vessel')} onClick={() => void handleTabChange('vessel')}
data-tour="nav-vessel" data-tour="nav-vessel"
> >
<Ship size={18} /> <Ship size={18} />
@@ -521,7 +535,7 @@ function App() {
<button <button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`} className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')} onClick={() => void handleTabChange('crew')}
data-tour="nav-crew" data-tour="nav-crew"
> >
<Users size={18} /> <Users size={18} />
@@ -540,7 +554,7 @@ function App() {
<button <button
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`} className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')} onClick={() => void handleTabChange('stats')}
data-tour="nav-stats" data-tour="nav-stats"
> >
<BarChart2 size={18} /> <BarChart2 size={18} />
@@ -549,7 +563,7 @@ function App() {
<button <button
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`} className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
onClick={() => setActiveTab('settings')} onClick={() => void handleTabChange('settings')}
> >
<Settings size={18} /> <Settings size={18} />
{t('nav.settings')} {t('nav.settings')}
@@ -569,11 +583,15 @@ function App() {
)} )}
{activeTab === 'vessel' && ( {activeTab === 'vessel' && (
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly} /> <VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly || !isLogbookOwner} />
)} )}
{activeTab === 'crew' && ( {activeTab === 'crew' && (
<CrewForm logbookId={activeLogbookId} readOnly={logbookReadOnly} /> <CrewForm
logbookId={activeLogbookId}
readOnly={logbookReadOnly}
skipperReadOnly={!isLogbookOwner}
/>
)} )}
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && ( {activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
@@ -602,12 +620,14 @@ function App() {
export default function AppWrapper() { export default function AppWrapper() {
return ( return (
<DialogProvider> <DialogProvider>
<UnsavedChangesProvider>
<AppTourProvider> <AppTourProvider>
<PwaUpdatePrompt /> <PwaUpdatePrompt />
<App /> <App />
<AppTourOverlay /> <AppTourOverlay />
</AppTourProvider> </AppTourProvider>
<AppFooter /> <AppFooter />
</UnsavedChangesProvider>
</DialogProvider> </DialogProvider>
) )
} }
+23 -6
View File
@@ -15,12 +15,33 @@ interface SpotlightRect {
height: number height: number
} }
const TOOLTIP_EDGE_MARGIN = 16
const TOOLTIP_ESTIMATED_HEIGHT = 240
function buildCutoutClipPath(rect: SpotlightRect): string { function buildCutoutClipPath(rect: SpotlightRect): string {
const right = rect.left + rect.width const right = rect.left + rect.width
const bottom = rect.top + rect.height const bottom = rect.top + rect.height
return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)` return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)`
} }
function computeTooltipTop(spotlight: SpotlightRect): number {
const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
const below = spotlight.top + spotlight.height + 12
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
return below
}
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
if (above >= TOOLTIP_EDGE_MARGIN) {
return above
}
return Math.max(
TOOLTIP_EDGE_MARGIN,
Math.min(below, viewportBottom - TOOLTIP_ESTIMATED_HEIGHT)
)
}
export default function AppTourOverlay() { export default function AppTourOverlay() {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
@@ -111,12 +132,8 @@ export default function AppTourOverlay() {
const tooltipStyle = centered const tooltipStyle = centered
? undefined ? undefined
: spotlight : spotlight
? { ? { top: computeTooltipTop(spotlight) }
top: Math.min(window.innerHeight - 220, spotlight.top + spotlight.height + 12), : { top: '20%' }
left: Math.min(window.innerWidth - 340, Math.max(16, spotlight.left)),
maxWidth: '420px'
}
: { top: '20%', left: '50%', transform: 'translateX(-50%)', maxWidth: '420px' }
const backdropStyle = spotlight && !centered const backdropStyle = spotlight && !centered
? { clipPath: buildCutoutClipPath(spotlight) } ? { clipPath: buildCutoutClipPath(spotlight) }
+8
View File
@@ -13,6 +13,7 @@ import {
} from '../services/auth.js' } from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react' import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx' import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import BetaBadge from './BetaBadge.tsx'
interface AuthOnboardingProps { interface AuthOnboardingProps {
onAuthenticated: () => void onAuthenticated: () => void
@@ -272,6 +273,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</label> </label>
<input <input
type="password" type="password"
name="new-pin"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
maxLength={8} maxLength={8}
@@ -281,6 +283,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
onChange={(e) => setPinInput(e.target.value.replace(/\D/g, ''))} onChange={(e) => setPinInput(e.target.value.replace(/\D/g, ''))}
disabled={loading} disabled={loading}
required required
autoComplete="new-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }} style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/> />
</div> </div>
@@ -321,6 +324,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="input-group"> <div className="input-group">
<input <input
type="password" type="password"
name="pin"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
maxLength={8} maxLength={8}
@@ -330,6 +334,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
onChange={(e) => setPinLoginInput(e.target.value.replace(/\D/g, ''))} onChange={(e) => setPinLoginInput(e.target.value.replace(/\D/g, ''))}
disabled={loading} disabled={loading}
required required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }} style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/> />
</div> </div>
@@ -408,7 +413,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="auth-card glass"> <div className="auth-card glass">
<div className="auth-brand"> <div className="auth-brand">
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" /> <img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
<div className="auth-brand-title-row">
<h1>{t('app.name')}</h1> <h1>{t('app.name')}</h1>
<BetaBadge />
</div>
<p className="tagline">{t('auth.tagline')}</p> <p className="tagline">{t('auth.tagline')}</p>
</div> </div>
+19
View File
@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
interface BetaBadgeProps {
className?: string
}
export default function BetaBadge({ className = '' }: BetaBadgeProps) {
const { t } = useTranslation()
return (
<span
className={`beta-badge ${className}`.trim()}
title={t('app.beta_hint')}
aria-label={t('app.beta_hint')}
>
{t('app.beta')}
</span>
)
}
+26 -15
View File
@@ -12,6 +12,7 @@ import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide
interface CrewFormProps { interface CrewFormProps {
logbookId: string logbookId: string
readOnly?: boolean readOnly?: boolean
skipperReadOnly?: boolean
preloadedData?: any[] preloadedData?: any[]
} }
@@ -34,9 +35,15 @@ interface DecryptedCrew {
data: CrewMemberData data: CrewMemberData
} }
export default function CrewForm({ logbookId, readOnly = false, preloadedData }: CrewFormProps) { export default function CrewForm({
logbookId,
readOnly = false,
skipperReadOnly = false,
preloadedData
}: CrewFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { showConfirm } = useDialog() const { showConfirm } = useDialog()
const skipperFormReadOnly = readOnly || skipperReadOnly
// Skipper profile state // Skipper profile state
const [skipName, setSkipName] = useState('') const [skipName, setSkipName] = useState('')
@@ -192,7 +199,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
const handleSaveSkipper = async (e: React.FormEvent) => { const handleSaveSkipper = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (readOnly) return if (skipperFormReadOnly) return
setSavingSkipper(true) setSavingSkipper(true)
setError(null) setError(null)
setSkipperSuccess(false) setSkipperSuccess(false)
@@ -397,10 +404,14 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
{error && <div className="auth-error mb-4">{error}</div>} {error && <div className="auth-error mb-4">{error}</div>}
{skipperReadOnly && !readOnly && (
<p className="help-text mb-4">{t('crew.skipper_read_only_hint')}</p>
)}
<form onSubmit={handleSaveSkipper} className="vessel-form"> <form onSubmit={handleSaveSkipper} className="vessel-form">
<div className="form-grid"> <div className="form-grid">
<div className="vessel-photo-wrapper"> <div className="vessel-photo-wrapper">
<div className="vessel-photo-preview" onClick={readOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: readOnly ? 'default' : 'pointer' }}> <div className="vessel-photo-preview" onClick={skipperFormReadOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: skipperFormReadOnly ? 'default' : 'pointer' }}>
{skipPhoto ? ( {skipPhoto ? (
<img src={skipPhoto} alt={skipName || 'Skipper'} className="vessel-photo" /> <img src={skipPhoto} alt={skipName || 'Skipper'} className="vessel-photo" />
) : ( ) : (
@@ -408,7 +419,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
<User size={48} className="placeholder-icon" /> <User size={48} className="placeholder-icon" />
</div> </div>
)} )}
{!readOnly && ( {!skipperFormReadOnly && (
<div className="vessel-photo-overlay"> <div className="vessel-photo-overlay">
<Camera size={24} /> <Camera size={24} />
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span> <span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
@@ -416,7 +427,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
)} )}
</div> </div>
{!readOnly && ( {!skipperFormReadOnly && (
<div className="vessel-photo-actions"> <div className="vessel-photo-actions">
<button <button
type="button" type="button"
@@ -473,7 +484,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipName} value={skipName}
onChange={(e) => setSkipName(e.target.value)} onChange={(e) => setSkipName(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
required required
/> />
</div> </div>
@@ -485,7 +496,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipAddress} value={skipAddress}
onChange={(e) => setSkipAddress(e.target.value)} onChange={(e) => setSkipAddress(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -496,7 +507,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipBirthDate} value={skipBirthDate}
onChange={(e) => setSkipBirthDate(e.target.value)} onChange={(e) => setSkipBirthDate(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -507,7 +518,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipPhone} value={skipPhone}
onChange={(e) => setSkipPhone(e.target.value)} onChange={(e) => setSkipPhone(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -518,7 +529,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipNationality} value={skipNationality}
onChange={(e) => setSkipNationality(e.target.value)} onChange={(e) => setSkipNationality(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -529,7 +540,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipPassport} value={skipPassport}
onChange={(e) => setSkipPassport(e.target.value)} onChange={(e) => setSkipPassport(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -540,7 +551,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipBloodType} value={skipBloodType}
onChange={(e) => setSkipBloodType(e.target.value)} onChange={(e) => setSkipBloodType(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -551,7 +562,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipAllergies} value={skipAllergies}
onChange={(e) => setSkipAllergies(e.target.value)} onChange={(e) => setSkipAllergies(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
@@ -562,12 +573,12 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text" className="input-text"
value={skipDiseases} value={skipDiseases}
onChange={(e) => setSkipDiseases(e.target.value)} onChange={(e) => setSkipDiseases(e.target.value)}
disabled={savingSkipper || readOnly} disabled={savingSkipper || skipperFormReadOnly}
/> />
</div> </div>
</div> </div>
{!readOnly && ( {!skipperFormReadOnly && (
<div className="form-actions"> <div className="form-actions">
{skipperSuccess && ( {skipperSuccess && (
<div className="success-toast"> <div className="success-toast">
+3 -3
View File
@@ -372,7 +372,7 @@ export default function LogEntriesList({
<Calendar size={24} className="form-icon" /> <Calendar size={24} className="form-icon" />
<h2>{t('logs.title')}</h2> <h2>{t('logs.title')}</h2>
</div> </div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> <div className="section-toolbar">
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}> <button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
<Download size={16} /> <Download size={16} />
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span> <span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
@@ -384,9 +384,9 @@ export default function LogEntriesList({
</button> </button>
{!readOnly && ( {!readOnly && (
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}> <button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
<Plus size={16} /> <Plus size={16} />
{t('logs.new_entry')} <span className="hide-mobile">{t('logs.new_entry')}</span>
</button> </button>
)} )}
</div> </div>
+201 -66
View File
@@ -16,11 +16,13 @@ import {
fingerprintSignature, fingerprintSignature,
normalizedSerializedSignature, normalizedSerializedSignature,
isPasskeySignature, isPasskeySignature,
isClassicSignature,
createClassicSignature,
isSignatureValidForEntry, isSignatureValidForEntry,
hasAnySignature hasAnySignature
} from '../utils/signatures.js' } from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js' import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload, type LogEventPayload } from '../utils/logEntryPayload.js' import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, type LogEventPayload } from '../utils/logEntryPayload.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js' import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js' import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js' import { getLogbookAccess } from '../services/logbookAccess.js'
@@ -35,6 +37,8 @@ import {
type SavedTrack type SavedTrack
} from '../services/trackUpload.js' } from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js' import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
function emptyTankLevels() { function emptyTankLevels() {
return { morning: 0, refilled: 0, evening: 0, consumption: 0 } return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
@@ -46,6 +50,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
const trackDistance = decrypted.trackDistanceNm const trackDistance = decrypted.trackDistanceNm
const trackSpeedMax = decrypted.trackSpeedMaxKn const trackSpeedMax = decrypted.trackSpeedMaxKn
const trackSpeedAvg = decrypted.trackSpeedAvgKn const trackSpeedAvg = decrypted.trackSpeedAvgKn
const motorHoursRaw = decrypted.motorHours
const payload = buildLogEntryPayload({ const payload = buildLogEntryPayload({
date: String(decrypted.date || ''), date: String(decrypted.date || ''),
@@ -76,6 +81,10 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
trackSpeedAvg != null && trackSpeedAvg !== '' trackSpeedAvg != null && trackSpeedAvg !== ''
? parseFloat(String(trackSpeedAvg)) ? parseFloat(String(trackSpeedAvg))
: undefined, : undefined,
motorHours:
motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw))
: undefined,
events: (decrypted.events as LogEventPayload[]) || [] events: (decrypted.events as LogEventPayload[]) || []
}) })
@@ -137,7 +146,7 @@ export default function LogEntryEditor({
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('') const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('') const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
const [canSignSkipper, setCanSignSkipper] = useState(false) const [canSignSkipper, setCanSignSkipper] = useState(false)
const [hasWriteCollaborators, setHasWriteCollaborators] = useState(false) const [canSignCrew, setCanSignCrew] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine) const [isOnline, setIsOnline] = useState(navigator.onLine)
const [entryHash, setEntryHash] = useState('') const [entryHash, setEntryHash] = useState('')
@@ -146,6 +155,9 @@ export default function LogEntryEditor({
const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('') const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('')
const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('') const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('')
// Motor hours under engine propulsion (per travel day)
const [motorHours, setMotorHours] = useState('')
// Events list state // Events list state
const [events, setEvents] = useState<LogEvent[]>([]) const [events, setEvents] = useState<LogEvent[]>([])
@@ -206,6 +218,11 @@ export default function LogEntryEditor({
if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') { if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') {
setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn)) setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn))
} }
if (entry?.motorHours != null && entry.motorHours !== '') {
setMotorHours(String(entry.motorHours))
} else {
setMotorHours('')
}
} }
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => { const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
@@ -229,16 +246,22 @@ export default function LogEntryEditor({
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined, trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined, trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined, trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
events: eventsOverride ?? events events: eventsOverride ?? events
}) })
}, [ }, [
date, dayOfTravel, departure, destination, date, dayOfTravel, departure, destination,
fwMorning, fwRefilled, fwEvening, fwConsumption, fwMorning, fwRefilled, fwEvening, fwConsumption,
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption, fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
events events
]) ])
const fuelPerMotorHour = useMemo(
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
[fuelConsumption, motorHours]
)
const currentFingerprint = useMemo(() => { const currentFingerprint = useMemo(() => {
const payload = buildPayloadForSigning() const payload = buildPayloadForSigning()
return JSON.stringify({ return JSON.stringify({
@@ -248,7 +271,60 @@ export default function LogEntryEditor({
}) })
}, [buildPayloadForSigning, signSkipper, signCrew]) }, [buildPayloadForSigning, signSkipper, signCrew])
const isDirty = savedFingerprint !== null && currentFingerprint !== savedFingerprint const buildEventFromForm = (): LogEvent =>
normalizeLogEvent({
time: evTime,
mgk: evMgk,
rwk: evRwk,
windPressure: evWindPressure,
windDirection: evWindDirection,
windStrength: evWindStrength,
seaState: evSeaState,
weatherIcon: evWeatherIcon,
current: evCurrent,
heel: evHeel,
sailsOrMotor: evSailsOrMotor,
logReading: evLogReading,
distance: evDistance,
gpsLat: evGpsLat,
gpsLng: evGpsLng,
remarks: evRemarks
})
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
if (editingEventIndex !== null) {
return sortLogEventsByTime(events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev)))
}
return sortLogEventsByTime([...events, eventData])
}
const hasPendingEventForm = useMemo(() => {
if (!evTime.trim()) return false
const draft = buildEventFromForm()
if (editingEventIndex !== null) {
const original = events[editingEventIndex]
return original ? !logEventsEqual(draft, original) : false
}
return true
}, [
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
evGpsLat, evGpsLng, evRemarks, editingEventIndex, events
])
const isDirty = savedFingerprint !== null && (
currentFingerprint !== savedFingerprint || hasPendingEventForm
)
const { confirmLeave } = useRegisterUnsavedChanges(
`log-entry-${entryId}`,
!readOnly && !loading && isDirty
)
const handleBack = async () => {
if (!(await confirmLeave())) return
onBack()
}
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => { const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
if (readOnly) return if (readOnly) return
@@ -308,8 +384,11 @@ export default function LogEntryEditor({
useEffect(() => { useEffect(() => {
getLogbookAccess(logbookId).then((access) => { getLogbookAccess(logbookId).then((access) => {
if (!access) return if (!access) return
setCanSignSkipper(access.isOwner || access.role === 'WRITE') setCanSignSkipper(access.isOwner)
setHasWriteCollaborators(access.writeCollaboratorCount > 0) setCanSignCrew(
access.role === 'WRITE' ||
(access.isOwner && access.writeCollaboratorCount === 0)
)
}) })
}, [logbookId]) }, [logbookId])
@@ -375,6 +454,7 @@ export default function LogEntryEditor({
const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash) const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash)
const handlePasskeySignSkipper = async () => { const handlePasskeySignSkipper = async () => {
if (!canSignSkipper) return
const confirmed = await confirmSignWarning() const confirmed = await confirmSignWarning()
if (!confirmed) return if (!confirmed) return
@@ -392,6 +472,7 @@ export default function LogEntryEditor({
} }
const handlePasskeySignCrew = async () => { const handlePasskeySignCrew = async () => {
if (!canSignCrew) return
const confirmed = await confirmSignWarning() const confirmed = await confirmSignWarning()
if (!confirmed) return if (!confirmed) return
@@ -483,7 +564,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '') setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '') setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
loadTrackStatsFromEntry(preloadedEntry) loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || []) setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry)) setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
return return
} }
@@ -516,7 +597,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '') setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '') setSignCrew(normalizeSignature(decrypted.signCrew) || '')
loadTrackStatsFromEntry(decrypted) loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || []) setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(decrypted)) setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
} }
} }
@@ -783,25 +864,6 @@ export default function LogEntryEditor({
return currentItems.includes(item.toLowerCase()) return currentItems.includes(item.toLowerCase())
} }
const buildEventFromForm = (): LogEvent => ({
time: evTime,
mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
})
const clearEventForm = () => { const clearEventForm = () => {
setEvTime('') setEvTime('')
setEvMgk('') setEvMgk('')
@@ -824,22 +886,23 @@ export default function LogEntryEditor({
} }
const fillEventForm = (ev: LogEvent) => { const fillEventForm = (ev: LogEvent) => {
setEvTime(ev.time) const normalized = normalizeLogEvent(ev)
setEvMgk(ev.mgk) setEvTime(normalized.time)
setEvRwk(ev.rwk) setEvMgk(normalized.mgk)
setEvWindPressure(ev.windPressure) setEvRwk(normalized.rwk)
setEvWindDirection(ev.windDirection) setEvWindPressure(normalized.windPressure)
setEvWindStrength(ev.windStrength) setEvWindDirection(normalized.windDirection)
setEvSeaState(ev.seaState) setEvWindStrength(normalized.windStrength)
setEvWeatherIcon(ev.weatherIcon) setEvSeaState(normalized.seaState)
setEvCurrent(ev.current) setEvWeatherIcon(normalized.weatherIcon)
setEvHeel(ev.heel) setEvCurrent(normalized.current)
setEvSailsOrMotor(ev.sailsOrMotor) setEvHeel(normalized.heel)
setEvLogReading(ev.logReading) setEvSailsOrMotor(normalized.sailsOrMotor)
setEvDistance(ev.distance) setEvLogReading(normalized.logReading)
setEvGpsLat(ev.gpsLat) setEvDistance(normalized.distance)
setEvGpsLng(ev.gpsLng) setEvGpsLat(normalized.gpsLat)
setEvRemarks(ev.remarks) setEvGpsLng(normalized.gpsLng)
setEvRemarks(normalized.remarks)
setEvLocationName('') setEvLocationName('')
} }
@@ -866,27 +929,25 @@ export default function LogEntryEditor({
if (readOnly || !evTime) return if (readOnly || !evTime) return
const eventData = buildEventFromForm() const eventData = buildEventFromForm()
let nextEvents: LogEvent[] const isEdit = editingEventIndex !== null
const hadSkipperSignature = isEdit && !!signSkipper
if (editingEventIndex !== null) { if (hadSkipperSignature) {
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange() markSkipperSignatureClearedForEventChange()
nextEvents = events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev)) }
const nextEvents = applyEventFormToEvents(eventData)
try {
await persistEntryToDb(nextEvents)
setEvents(nextEvents)
clearEventForm()
if (hadSkipperSignature) { if (hadSkipperSignature) {
void showAlertRef.current( void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'), t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title') t('logs.sign_cleared_skipper_re_sign_title')
) )
} }
} else {
nextEvents = [...events, eventData]
}
setEvents(nextEvents)
clearEventForm()
try {
await persistEntryToDb(nextEvents)
} catch (err: any) { } catch (err: any) {
console.error('Failed to auto-save event:', err) console.error('Failed to auto-save event:', err)
setError(err.message || 'Failed to save event.') setError(err.message || 'Failed to save event.')
@@ -935,13 +996,28 @@ export default function LogEntryEditor({
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (readOnly || !isDirty) return if (readOnly) return
let eventsToSave = events
if (hasPendingEventForm) {
const isEdit = editingEventIndex !== null
if (isEdit && signSkipper) {
markSkipperSignatureClearedForEventChange()
}
eventsToSave = applyEventFormToEvents(buildEventFromForm())
setEvents(eventsToSave)
clearEventForm()
} else if (!isDirty) {
return
}
setSaving(true) setSaving(true)
setError(null) setError(null)
setSuccess(false) setSuccess(false)
try { try {
await persistEntryToDb() await persistEntryToDb(eventsToSave)
setSuccess(true) setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
@@ -972,7 +1048,7 @@ export default function LogEntryEditor({
<div className="form-card" style={{ paddingBottom: '20px' }}> <div className="form-card" style={{ paddingBottom: '20px' }}>
<div className="section-title-bar"> <div className="section-title-bar">
<div className="section-title-left"> <div className="section-title-left">
<button className="btn-back" onClick={onBack} style={{ padding: '6px 12px' }}> <button className="btn-back" onClick={() => void handleBack()} style={{ padding: '6px 12px' }}>
<ChevronLeft size={16} /> <ChevronLeft size={16} />
{t('logs.back_to_list')} {t('logs.back_to_list')}
</button> </button>
@@ -992,7 +1068,7 @@ export default function LogEntryEditor({
style={{ width: 'auto', padding: '8px 16px' }} style={{ width: 'auto', padding: '8px 16px' }}
> >
<Download size={16} /> <Download size={16} />
<span>{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span> <span className="hide-mobile">{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -1053,6 +1129,20 @@ export default function LogEntryEditor({
disabled={saving || readOnly} disabled={saving || readOnly}
/> />
</div> </div>
<div className="input-group">
<label>{t('logs.motor_hours')}</label>
<input
type="number"
className="input-text"
value={motorHours}
onChange={(e) => setMotorHours(e.target.value)}
disabled={saving || readOnly}
min="0"
step="0.1"
placeholder="0"
/>
</div>
</div> </div>
</div> </div>
@@ -1163,6 +1253,22 @@ export default function LogEntryEditor({
aria-readonly="true" aria-readonly="true"
/> />
</div> </div>
<div className="input-group">
<label>{t('logs.fuel_per_motor_hour')}</label>
<input
type="text"
className="input-text consumption-value"
value={
fuelPerMotorHour != null
? `${formatFuelPerMotorHour(fuelPerMotorHour)} L/h`
: '—'
}
readOnly
tabIndex={-1}
aria-readonly="true"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1567,15 +1673,16 @@ export default function LogEntryEditor({
)} )}
</span> </span>
</div> </div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> <div className="track-actions">
<button <button
type="button" type="button"
className="btn secondary" className="btn secondary"
onClick={() => downloadTrackFile(savedTrack)} onClick={() => downloadTrackFile(savedTrack)}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
title={t('logs.gps_tracking_btn_gpx')}
> >
<Download size={14} /> <Download size={14} />
{t('logs.gps_tracking_btn_gpx')} <span className="hide-mobile">{t('logs.gps_tracking_btn_gpx')}</span>
</button> </button>
{!readOnly && ( {!readOnly && (
<button <button
@@ -1583,9 +1690,10 @@ export default function LogEntryEditor({
className="btn secondary" className="btn secondary"
onClick={handleDeleteTrack} onClick={handleDeleteTrack}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
title={t('logs.gps_track_delete')}
> >
<Trash2 size={14} /> <Trash2 size={14} />
{t('logs.gps_track_delete')} <span className="hide-mobile">{t('logs.gps_track_delete')}</span>
</button> </button>
)} )}
</div> </div>
@@ -1646,13 +1754,40 @@ export default function LogEntryEditor({
disabled={saving} disabled={saving}
isOnline={isOnline} isOnline={isOnline}
canSignSkipper={canSignSkipper} canSignSkipper={canSignSkipper}
hasWriteCollaborators={hasWriteCollaborators} canSignCrew={canSignCrew}
signSkipper={signSkipper} signSkipper={signSkipper}
signCrew={signCrew} signCrew={signCrew}
skipperSignatureValid={skipperSignatureValid} skipperSignatureValid={skipperSignatureValid}
crewSignatureValid={crewSignatureValid} crewSignatureValid={crewSignatureValid}
onSignSkipperChange={setSignSkipper} onSignSkipperChange={(value) => {
onSignCrewChange={setSignCrew} if (canSignSkipper && !readOnly) setSignSkipper(value)
}}
onSignCrewChange={(value) => {
if (!canSignCrew || readOnly) return
if (!value) {
setSignCrew('')
return
}
if (isPasskeySignature(value) || isClassicSignature(value)) {
setSignCrew(value)
return
}
if (!canSignSkipper) {
const userId = localStorage.getItem('active_userid') || ''
const username = localStorage.getItem('active_username') || ''
if (userId && username) {
setSignCrew(createClassicSignature({
role: 'crew',
userId,
username,
signedAt: new Date().toISOString(),
payload: value
}))
return
}
}
setSignCrew(value)
}}
onPasskeySignSkipper={handlePasskeySignSkipper} onPasskeySignSkipper={handlePasskeySignSkipper}
onPasskeySignCrew={handlePasskeySignCrew} onPasskeySignCrew={handlePasskeySignCrew}
onBeforeSign={confirmSignWarning} onBeforeSign={confirmSignWarning}
+22 -4
View File
@@ -58,6 +58,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
const handleExportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleExport()
}
const handleImportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleRestore()
}
const handleExport = async () => { const handleExport = async () => {
setError(null) setError(null)
setSuccess(null) setSuccess(null)
@@ -209,10 +219,12 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</h4> </h4>
<p className="text-muted backup-section-desc">{t('settings.backup_export_desc')}</p> <p className="text-muted backup-section-desc">{t('settings.backup_export_desc')}</p>
<form onSubmit={handleExportSubmit} className="backup-export-form">
<div className="input-group"> <div className="input-group">
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label> <label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
<input <input
id="backup-export-passphrase" id="backup-export-passphrase"
name="backup-export-passphrase"
type="password" type="password"
className="input-text" className="input-text"
value={exportPassphrase} value={exportPassphrase}
@@ -220,29 +232,32 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
placeholder={t('settings.backup_passphrase_placeholder')} placeholder={t('settings.backup_passphrase_placeholder')}
autoComplete="new-password" autoComplete="new-password"
disabled={exporting} disabled={exporting}
required
/> />
</div> </div>
<div className="input-group"> <div className="input-group">
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label> <label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
<input <input
id="backup-export-confirm" id="backup-export-confirm"
name="backup-export-confirm"
type="password" type="password"
className="input-text" className="input-text"
value={exportConfirm} value={exportConfirm}
onChange={(e) => setExportConfirm(e.target.value)} onChange={(e) => setExportConfirm(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
disabled={exporting} disabled={exporting}
required
/> />
</div> </div>
<button <button
type="button" type="submit"
className="btn primary" className="btn primary"
onClick={handleExport}
disabled={exporting || !exportPassphrase || !exportConfirm} disabled={exporting || !exportPassphrase || !exportConfirm}
> >
<Download size={16} /> <Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')} {exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button> </button>
</form>
</section> </section>
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading"> <section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
@@ -252,6 +267,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</h4> </h4>
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p> <p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
<form onSubmit={handleImportSubmit} className="backup-import-form">
<div className="input-group"> <div className="input-group">
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label> <label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
<input <input
@@ -271,6 +287,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label> <label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
<input <input
id="backup-import-passphrase" id="backup-import-passphrase"
name="backup-import-passphrase"
type="password" type="password"
className="input-text" className="input-text"
value={importPassphrase} value={importPassphrase}
@@ -280,6 +297,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
}} }}
autoComplete="current-password" autoComplete="current-password"
disabled={importing} disabled={importing}
required
/> />
</div> </div>
@@ -293,9 +311,8 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')} {previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button> </button>
<button <button
type="button" type="submit"
className="btn primary" className="btn primary"
onClick={() => handleRestore()}
disabled={importing || !importPassphrase} disabled={importing || !importPassphrase}
> >
<Upload size={16} /> <Upload size={16} />
@@ -304,6 +321,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</div> </div>
</> </>
)} )}
</form>
{importPreview && ( {importPreview && (
<div className="backup-preview glass"> <div className="backup-preview glass">
@@ -4,6 +4,7 @@ import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js' import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx' import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { logoutUser } from '../services/auth.js' import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
@@ -177,7 +178,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<div className="header-brand"> <div className="header-brand">
<Ship className="header-logo" size={32} /> <Ship className="header-logo" size={32} />
<div> <div>
<div className="header-brand-title-row">
<h1>{t('app.name')}</h1> <h1>{t('app.name')}</h1>
<BetaBadge />
</div>
<p className="subtitle">{t('app.tagline')}</p> <p className="subtitle">{t('app.tagline')}</p>
</div> </div>
</div> </div>
-1
View File
@@ -233,7 +233,6 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
capture="environment"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileChange} onChange={handleFileChange}
style={{ display: 'none' }} style={{ display: 'none' }}
+2
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { decryptJson } from '../services/crypto.js' import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx' import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx' import CrewForm from './CrewForm.tsx'
import LogEntriesList from './LogEntriesList.tsx' import LogEntriesList from './LogEntriesList.tsx'
@@ -124,6 +125,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
} }
} }
setGpsTracks(decGpsTracks) setGpsTracks(decGpsTracks)
trackPlausibleEvent(PlausibleEvents.PUBLIC_LINK_OPENED)
} catch (err: any) { } catch (err: any) {
console.error(err) console.error(err)
+6 -3
View File
@@ -111,6 +111,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const logbookKey = await ensureLogbookKey(logbookId) const logbookKey = await ensureLogbookKey(logbookId)
const hexKey = bufferToHex(logbookKey) const hexKey = bufferToHex(logbookKey)
setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`) setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`)
trackPlausibleEvent(PlausibleEvents.LOGBOOK_SHARED)
showAlert('Public share link enabled!') showAlert('Public share link enabled!')
} else { } else {
setShareEnabled(false) setShareEnabled(false)
@@ -292,12 +293,14 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</label> </label>
<input <input
id="owm-api-key" id="owm-api-key"
name="owm-api-key"
type="password" type="password"
className="input-text" className="input-text"
placeholder="e.g. 8b6a7f...d8" placeholder="e.g. 8b6a7f...d8"
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
disabled={saving} disabled={saving}
autoComplete="off"
/> />
</div> </div>
</div> </div>
@@ -413,7 +416,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div> </div>
{shareEnabled && shareLink && ( {shareEnabled && shareLink && (
<div className="input-group mb-4" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> <div className="input-group mb-4 copy-link-row">
<input <input
type="text" type="text"
readOnly readOnly
@@ -454,7 +457,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
{t('logs.invite_link_desc')} {t('logs.invite_link_desc')}
</p> </p>
<div className="form-actions" style={{ justifyContent: 'flex-start', gap: '12px', marginBottom: '20px' }}> <div className="form-actions form-actions--start" style={{ gap: '12px', marginBottom: '20px' }}>
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
@@ -468,7 +471,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div> </div>
{inviteLink && ( {inviteLink && (
<div className="input-group mb-6" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> <div className="input-group mb-6 copy-link-row">
<input <input
type="text" type="text"
readOnly readOnly
+27 -9
View File
@@ -4,7 +4,7 @@ import { Check } from 'lucide-react'
import SignaturePad from './SignaturePad.tsx' import SignaturePad from './SignaturePad.tsx'
import PasskeySignButton from './PasskeySignButton.tsx' import PasskeySignButton from './PasskeySignButton.tsx'
import type { PasskeySignature, SignatureValue } from '../types/signatures.js' import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
import { isPasskeySignature } from '../utils/signatures.js' import { isPasskeySignature, getSignaturePayload, getSignatureAttribution } from '../utils/signatures.js'
type SignatureMode = 'passkey' | 'classic' type SignatureMode = 'passkey' | 'classic'
@@ -13,7 +13,7 @@ interface SignatureSectionProps {
disabled?: boolean disabled?: boolean
isOnline: boolean isOnline: boolean
canSignSkipper: boolean canSignSkipper: boolean
hasWriteCollaborators: boolean canSignCrew: boolean
signSkipper: SignatureValue | '' signSkipper: SignatureValue | ''
signCrew: SignatureValue | '' signCrew: SignatureValue | ''
skipperSignatureValid: boolean skipperSignatureValid: boolean
@@ -25,14 +25,30 @@ interface SignatureSectionProps {
onBeforeSign?: () => Promise<boolean> onBeforeSign?: () => Promise<boolean>
} }
function SignerAttributionBadge({ value }: { value: SignatureValue | '' }) {
const { t, i18n } = useTranslation()
const attribution = getSignatureAttribution(value)
if (!attribution) return null
const formattedDate = new Date(attribution.signedAt).toLocaleString(
i18n.language === 'de' ? 'de-DE' : 'en-GB'
)
return (
<div className="passkey-sign-badge valid signature-attribution-badge">
<span>{t('logs.sign_passkey_signed', { username: attribution.username })}</span>
<span className="passkey-sign-date">{formattedDate}</span>
</div>
)
}
function padValue(value: SignatureValue | ''): string { function padValue(value: SignatureValue | ''): string {
if (!value || isPasskeySignature(value)) return '' return getSignaturePayload(value)
return value
} }
function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode { function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode {
if (isPasskeySignature(value)) return 'passkey' if (isPasskeySignature(value)) return 'passkey'
if (value) return 'classic' if (getSignaturePayload(value)) return 'classic'
return passkeyAvailable ? 'passkey' : 'classic' return passkeyAvailable ? 'passkey' : 'classic'
} }
@@ -108,6 +124,7 @@ function RoleSignatureBlock({
} }
return ( return (
<div className="signature-role-block"> <div className="signature-role-block">
<SignerAttributionBadge value={value} />
<SignaturePad <SignaturePad
id={padId} id={padId}
label={roleLabel} label={roleLabel}
@@ -162,6 +179,7 @@ function RoleSignatureBlock({
{showClassicPanel && ( {showClassicPanel && (
<> <>
<SignerAttributionBadge value={value} />
<SignaturePad <SignaturePad
id={padId} id={padId}
label={roleLabel} label={roleLabel}
@@ -189,7 +207,7 @@ export default function SignatureSection({
disabled = false, disabled = false,
isOnline, isOnline,
canSignSkipper, canSignSkipper,
hasWriteCollaborators, canSignCrew,
signSkipper, signSkipper,
signCrew, signCrew,
skipperSignatureValid, skipperSignatureValid,
@@ -203,7 +221,7 @@ export default function SignatureSection({
const { t } = useTranslation() const { t } = useTranslation()
const showSkipperPasskey = canSignSkipper && isOnline const showSkipperPasskey = canSignSkipper && isOnline
const showCrewPasskey = hasWriteCollaborators && isOnline const showCrewPasskey = canSignCrew && isOnline
const hasSignature = !!(signSkipper || signCrew) const hasSignature = !!(signSkipper || signCrew)
return ( return (
@@ -228,7 +246,7 @@ export default function SignatureSection({
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined} passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
signatureValid={skipperSignatureValid} signatureValid={skipperSignatureValid}
showPasskey={showSkipperPasskey} showPasskey={showSkipperPasskey}
readOnly={readOnly} readOnly={readOnly || !canSignSkipper}
disabled={disabled} disabled={disabled}
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined} classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined} offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined}
@@ -245,7 +263,7 @@ export default function SignatureSection({
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined} passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
signatureValid={crewSignatureValid} signatureValid={crewSignatureValid}
showPasskey={showCrewPasskey} showPasskey={showCrewPasskey}
readOnly={readOnly} readOnly={readOnly || !canSignCrew}
disabled={disabled} disabled={disabled}
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined} classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
onChange={onSignCrewChange} onChange={onSignCrewChange}
+83 -1
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge } from 'lucide-react' import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge, Timer } from 'lucide-react'
import MultiTrackMap from './MultiTrackMap.tsx' import MultiTrackMap from './MultiTrackMap.tsx'
import { import {
formatLiters, formatLiters,
formatHours,
formatNm, formatNm,
loadAccountStats, loadAccountStats,
loadLogbookStats, loadLogbookStats,
@@ -12,6 +13,7 @@ import {
type TravelDayStats type TravelDayStats
} from '../services/statsAggregation.js' } from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js' import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
interface StatsDashboardProps { interface StatsDashboardProps {
logbookId: string logbookId: string
@@ -78,12 +80,26 @@ function TotalsGrid({ totals }: { totals: StatsTotals }) {
value={formatNm(totals.motorDistanceNm)} value={formatNm(totals.motorDistanceNm)}
unit={t('stats.unit_nm')} unit={t('stats.unit_nm')}
/> />
<KpiCard
icon={<Timer size={20} />}
label={t('stats.motor_hours_total')}
value={formatHours(totals.totalMotorHours)}
unit={t('stats.unit_h')}
/>
<KpiCard <KpiCard
icon={<Fuel size={20} />} icon={<Fuel size={20} />}
label={t('stats.fuel_total')} label={t('stats.fuel_total')}
value={formatLiters(totals.totalFuelL)} value={formatLiters(totals.totalFuelL)}
unit={t('stats.unit_l')} unit={t('stats.unit_l')}
/> />
{totals.fuelPerMotorHourL != null && (
<KpiCard
icon={<Timer size={20} />}
label={t('stats.fuel_per_motor_hour')}
value={formatFuelPerMotorHour(totals.fuelPerMotorHourL)}
unit={`${t('stats.unit_l')}/${t('stats.unit_h')}`}
/>
)}
<KpiCard <KpiCard
icon={<Droplets size={20} />} icon={<Droplets size={20} />}
label={t('stats.water_total')} label={t('stats.water_total')}
@@ -247,6 +263,36 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
/> />
</div> </div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
<p className="stats-section-sub">
{t('stats.avg_motor_hours')}: {formatHours(totals.avgMotorHoursPerDay)} {t('stats.unit_h')}
{totals.fuelPerMotorHourL != null && (
<>
{' · '}
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</>
)}
</p>
<DailyBarChart
days={travelDays}
valueFn={(d) => d.motorHours}
barClass="stats-bar--motor-hours"
formatValue={formatHours}
/>
{travelDays.some((d) => d.fuelPerMotorHourL != null) && (
<>
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
<DailyBarChart
days={travelDays}
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
barClass="stats-bar--fuel-per-hour"
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
/>
</>
)}
</div>
<div className="member-editor-card glass mt-6"> <div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3> <h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
<p className="stats-section-sub"> <p className="stats-section-sub">
@@ -256,6 +302,9 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
{totals.fuelPerNmL != null && ( {totals.fuelPerNmL != null && (
<> · {t('stats.fuel_per_nm')}: {totals.fuelPerNmL} {t('stats.unit_l')}/{t('stats.unit_nm')}</> <> · {t('stats.fuel_per_nm')}: {totals.fuelPerNmL} {t('stats.unit_l')}/{t('stats.unit_nm')}</>
)} )}
{totals.fuelPerMotorHourL != null && (
<> · {t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}</>
)}
</p> </p>
<ConsumptionChart days={travelDays} /> <ConsumptionChart days={travelDays} />
</div> </div>
@@ -367,6 +416,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<th>{t('stats.travel_days')}</th> <th>{t('stats.travel_days')}</th>
<th>{t('stats.total_distance')}</th> <th>{t('stats.total_distance')}</th>
<th>{t('stats.fuel_total')}</th> <th>{t('stats.fuel_total')}</th>
<th>{t('stats.motor_hours_total')}</th>
<th>{t('stats.water_total')}</th> <th>{t('stats.water_total')}</th>
</tr> </tr>
</thead> </thead>
@@ -377,6 +427,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<td>{lb.totals.travelDayCount}</td> <td>{lb.totals.travelDayCount}</td>
<td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</td> <td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</td>
<td>{formatLiters(lb.totals.totalFuelL)} {t('stats.unit_l')}</td> <td>{formatLiters(lb.totals.totalFuelL)} {t('stats.unit_l')}</td>
<td>{formatHours(lb.totals.totalMotorHours)} {t('stats.unit_h')}</td>
<td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td> <td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td>
</tr> </tr>
))} ))}
@@ -397,8 +448,39 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
/> />
</div> </div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
{accountStats.totals.fuelPerMotorHourL != null && (
<p className="stats-section-sub">
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</p>
)}
<DailyBarChart
days={allAccountDays}
valueFn={(d) => d.motorHours}
barClass="stats-bar--motor-hours"
formatValue={formatHours}
/>
{allAccountDays.some((d) => d.fuelPerMotorHourL != null) && (
<>
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
<DailyBarChart
days={allAccountDays}
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
barClass="stats-bar--fuel-per-hour"
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
/>
</>
)}
</div>
<div className="member-editor-card glass mt-6"> <div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3> <h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
{accountStats.totals.fuelPerMotorHourL != null && (
<p className="stats-section-sub">
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</p>
)}
<ConsumptionChart days={allAccountDays} /> <ConsumptionChart days={allAccountDays} />
</div> </div>
@@ -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 }
}
+19 -1
View File
@@ -2,7 +2,15 @@
"translation": { "translation": {
"app": { "app": {
"name": "Kapteins Daagbok", "name": "Kapteins Daagbok",
"tagline": "Privates Yacht-Logbuch" "tagline": "Privates Yacht-Logbuch",
"beta": "Beta",
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
},
"common": {
"unsaved_changes_title": "Ungespeicherte Änderungen",
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
"unsaved_changes_leave": "Verlassen",
"unsaved_changes_stay": "Bleiben"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -143,6 +151,7 @@
"sign_passkey_signing": "Passkey wird angefordert…", "sign_passkey_signing": "Passkey wird angefordert…",
"sign_passkey_signed": "Freigegeben von {{username}}", "sign_passkey_signed": "Freigegeben von {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})", "sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Passkey-Freigabe entfernen", "sign_passkey_clear": "Passkey-Freigabe entfernen",
"sign_mode_passkey": "Passkey", "sign_mode_passkey": "Passkey",
"sign_mode_classic": "Klassisch", "sign_mode_classic": "Klassisch",
@@ -196,6 +205,8 @@
"event_heel": "Krängung (°)", "event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor", "event_sails": "Segelführung / Motor",
"motor_propulsion": "Maschinenfahrt", "motor_propulsion": "Maschinenfahrt",
"motor_hours": "Maschinenstunden (gesamt)",
"fuel_per_motor_hour": "Verbrauch pro Maschinenstunde",
"event_distance": "Distanz (sm)", "event_distance": "Distanz (sm)",
"export_csv": "CSV herunterladen", "export_csv": "CSV herunterladen",
"share_csv": "CSV teilen", "share_csv": "CSV teilen",
@@ -263,6 +274,7 @@
"crew": { "crew": {
"title": "Skipper- & Crew-Profile", "title": "Skipper- & Crew-Profile",
"skipper_section": "Skipper-Profil", "skipper_section": "Skipper-Profil",
"skipper_read_only_hint": "Das Skipper-Profil kann nur vom Logbuch-Eigner bearbeitet werden.",
"crew_section": "Crew-Liste", "crew_section": "Crew-Liste",
"add_crew": "Crew-Mitglied hinzufügen", "add_crew": "Crew-Mitglied hinzufügen",
"edit_crew": "Crew-Mitglied bearbeiten", "edit_crew": "Crew-Mitglied bearbeiten",
@@ -469,6 +481,9 @@
"travel_days": "Reisetage", "travel_days": "Reisetage",
"sail_distance": "Unter Segel", "sail_distance": "Unter Segel",
"motor_distance": "Maschinenfahrt", "motor_distance": "Maschinenfahrt",
"motor_hours_total": "Maschinenstunden gesamt",
"daily_motor_hours": "Maschinenstunden pro Reisetag",
"avg_motor_hours": "Ø Maschinenstunden pro Reisetag",
"unknown_propulsion": "Unbekannt", "unknown_propulsion": "Unbekannt",
"fuel_total": "Kraftstoff gesamt", "fuel_total": "Kraftstoff gesamt",
"water_total": "Wasser gesamt", "water_total": "Wasser gesamt",
@@ -482,9 +497,12 @@
"avg_fuel": "Ø Kraftstoff", "avg_fuel": "Ø Kraftstoff",
"avg_water": "Ø Wasser", "avg_water": "Ø Wasser",
"fuel_per_nm": "Kraftstoff pro sm", "fuel_per_nm": "Kraftstoff pro sm",
"fuel_per_motor_hour": "Kraftstoff pro Maschinenstunde",
"daily_fuel_per_motor_hour": "Kraftstoffverbrauch pro Maschinenstunde je Reisetag",
"fuel_legend": "Kraftstoff", "fuel_legend": "Kraftstoff",
"water_legend": "Wasser", "water_legend": "Wasser",
"unit_nm": "sm", "unit_nm": "sm",
"unit_h": "h",
"unit_l": "L", "unit_l": "L",
"day_label": "Tag {{day}}", "day_label": "Tag {{day}}",
"account_logbooks": "Logbücher im Überblick", "account_logbooks": "Logbücher im Überblick",
+19 -1
View File
@@ -2,7 +2,15 @@
"translation": { "translation": {
"app": { "app": {
"name": "Kapteins Daagbok", "name": "Kapteins Daagbok",
"tagline": "Private Yacht Logbook" "tagline": "Private Yacht Logbook",
"beta": "Beta",
"beta_hint": "Beta release — features may still change"
},
"common": {
"unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
"unsaved_changes_leave": "Leave",
"unsaved_changes_stay": "Stay"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -143,6 +151,7 @@
"sign_passkey_signing": "Requesting Passkey…", "sign_passkey_signing": "Requesting Passkey…",
"sign_passkey_signed": "Signed by {{username}}", "sign_passkey_signed": "Signed by {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})", "sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Remove Passkey signature", "sign_passkey_clear": "Remove Passkey signature",
"sign_mode_passkey": "Passkey", "sign_mode_passkey": "Passkey",
"sign_mode_classic": "Classic", "sign_mode_classic": "Classic",
@@ -196,6 +205,8 @@
"event_heel": "Heel Angle (°)", "event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status", "event_sails": "Sails / Motor Status",
"motor_propulsion": "Engine Propulsion", "motor_propulsion": "Engine Propulsion",
"motor_hours": "Engine hours (total)",
"fuel_per_motor_hour": "Consumption per engine hour",
"event_distance": "Distance (nm)", "event_distance": "Distance (nm)",
"export_csv": "Download CSV", "export_csv": "Download CSV",
"share_csv": "Share CSV", "share_csv": "Share CSV",
@@ -263,6 +274,7 @@
"crew": { "crew": {
"title": "Skipper & Crew Profiles", "title": "Skipper & Crew Profiles",
"skipper_section": "Skipper Profile", "skipper_section": "Skipper Profile",
"skipper_read_only_hint": "The skipper profile can only be edited by the logbook owner.",
"crew_section": "Crew List", "crew_section": "Crew List",
"add_crew": "Add Crew Member", "add_crew": "Add Crew Member",
"edit_crew": "Edit Crew Member", "edit_crew": "Edit Crew Member",
@@ -469,6 +481,9 @@
"travel_days": "Travel days", "travel_days": "Travel days",
"sail_distance": "Under sail", "sail_distance": "Under sail",
"motor_distance": "Engine", "motor_distance": "Engine",
"motor_hours_total": "Total engine hours",
"daily_motor_hours": "Engine hours per travel day",
"avg_motor_hours": "Avg. engine hours per travel day",
"unknown_propulsion": "Unknown", "unknown_propulsion": "Unknown",
"fuel_total": "Total fuel", "fuel_total": "Total fuel",
"water_total": "Total water", "water_total": "Total water",
@@ -482,9 +497,12 @@
"avg_fuel": "Avg. fuel", "avg_fuel": "Avg. fuel",
"avg_water": "Avg. water", "avg_water": "Avg. water",
"fuel_per_nm": "Fuel per nm", "fuel_per_nm": "Fuel per nm",
"fuel_per_motor_hour": "Fuel per engine hour",
"daily_fuel_per_motor_hour": "Fuel consumption per engine hour by travel day",
"fuel_legend": "Fuel", "fuel_legend": "Fuel",
"water_legend": "Water", "water_legend": "Water",
"unit_nm": "nm", "unit_nm": "nm",
"unit_h": "h",
"unit_l": "L", "unit_l": "L",
"day_label": "Day {{day}}", "day_label": "Day {{day}}",
"account_logbooks": "Logbooks overview", "account_logbooks": "Logbooks overview",
+2
View File
@@ -14,6 +14,8 @@ export const PlausibleEvents = {
ONBOARDING_TOUR_SKIPPED: 'Onboarding Tour Skipped', ONBOARDING_TOUR_SKIPPED: 'Onboarding Tour Skipped',
INVITE_GENERATED: 'Invite Generated', INVITE_GENERATED: 'Invite Generated',
INVITE_ACCEPTED: 'Invite Accepted', INVITE_ACCEPTED: 'Invite Accepted',
LOGBOOK_SHARED: 'Logbook Shared',
PUBLIC_LINK_OPENED: 'Public Link Opened',
PDF_EXPORTED: 'PDF Exported', PDF_EXPORTED: 'PDF Exported',
CSV_EXPORTED: 'CSV Exported', CSV_EXPORTED: 'CSV Exported',
CSV_SHARED: 'CSV Shared', CSV_SHARED: 'CSV Shared',
+10 -4
View File
@@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js' import { decryptJson } from './crypto.js'
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js' import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import i18n from '../i18n/index.js' import i18n from '../i18n/index.js'
function escapeCsvValue(val: string | number | undefined | null): string { function escapeCsvValue(val: string | number | undefined | null): string {
@@ -79,7 +80,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const headers = [ const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
'Skipper Signature', 'Crew Signature', 'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course', 'Event Time', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)', 'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
@@ -95,6 +96,10 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
passkeyLabel: (username: string, signedAt: string) => { passkeyLabel: (username: string, signedAt: string) => {
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB') const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
return i18n.t('logs.sign_passkey_export', { username, date }) return i18n.t('logs.sign_passkey_export', { username, date })
},
attributionLabel: (username: string, signedAt: string) => {
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
return i18n.t('logs.sign_attribution_export', { username, date })
} }
}; };
@@ -108,6 +113,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const trackDist = entry.trackDistanceNm ?? ''; const trackDist = entry.trackDistanceNm ?? '';
const trackMax = entry.trackSpeedMaxKn ?? ''; const trackMax = entry.trackSpeedMaxKn ?? '';
const trackAvg = entry.trackSpeedAvgKn ?? ''; const trackAvg = entry.trackSpeedAvgKn ?? '';
const motorH = entry.motorHours ?? '';
const fwM = entry.freshwater?.morning ?? ''; const fwM = entry.freshwater?.morning ?? '';
const fwR = entry.freshwater?.refilled ?? ''; const fwR = entry.freshwater?.refilled ?? '';
const fwE = entry.freshwater?.evening ?? ''; const fwE = entry.freshwater?.evening ?? '';
@@ -123,7 +129,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, trackDist, trackMax, trackAvg, motorH,
'', '', '', '', '', '',
'', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
@@ -134,12 +140,12 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
].map(escapeCsvValue)); ].map(escapeCsvValue));
} else { } else {
// Sort events chronologically by time // Sort events chronologically by time
const sortedEvents = [...eventsList].sort((a, b) => (a.time || '').localeCompare(b.time || '')); const sortedEvents = sortLogEventsByTime(eventsList);
for (const ev of sortedEvents) { for (const ev of sortedEvents) {
rows.push([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '', ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '', ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '', ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
+8
View File
@@ -26,6 +26,7 @@ export interface DemoDaySpec {
filename: string filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number } freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number } fuel: { morning: number; refilled: number; evening: number; consumption: number }
motorHours?: number
events: Array<Record<string, string>> events: Array<Record<string, string>>
} }
@@ -100,6 +101,7 @@ export function buildDemoDays(): DemoDaySpec[] {
filename: 'laboe-damp.gpx', filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 }, freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 }, fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
motorHours: 1.5,
events: [ events: [
{ {
time: '09:00', time: '09:00',
@@ -247,6 +249,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
entryPayload.trackSpeedMaxKn = stats.speedMaxKn entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn entryPayload.trackSpeedAvgKn = stats.speedAvgKn
} }
if (day.motorHours != null && day.motorHours > 0) {
entryPayload.motorHours = day.motorHours
}
entries.push(entryPayload as PublicDemoFixture['entries'][number]) entries.push(entryPayload as PublicDemoFixture['entries'][number])
@@ -303,6 +308,9 @@ export function buildDemoEntryPayloads(): Array<{
entryPayload.trackSpeedMaxKn = stats.speedMaxKn entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn entryPayload.trackSpeedAvgKn = stats.speedAvgKn
} }
if (day.motorHours != null && day.motorHours > 0) {
entryPayload.motorHours = day.motorHours
}
return { return {
entryId, entryId,
+13 -4
View File
@@ -3,7 +3,8 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js' import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js' import { decryptJson } from './crypto.js'
import { isSignatureImage, isPasskeySignature } from '../utils/signatures.js' import { isSignatureImage, isPasskeySignature, isClassicSignature, getSignaturePayload } from '../utils/signatures.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import i18n from '../i18n/index.js' import i18n from '../i18n/index.js'
function formatPasskeySignDate(signedAt: string): string { function formatPasskeySignDate(signedAt: string): string {
@@ -132,7 +133,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
// Draw Data Rows // Draw Data Rows
const events = entry.events || []; const events = entry.events || [];
const maxRows = 16; const maxRows = 16;
const sortedEvents = [...events].sort((a: any, b: any) => (a.time || '').localeCompare(b.time || '')); const sortedEvents = sortLogEventsByTime(events);
doc.setFont('Helvetica', 'normal'); doc.setFont('Helvetica', 'normal');
@@ -255,8 +256,16 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt); const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
doc.text(`Passkey: ${entry.signCrew.username}`, sigX + 80.5, sigY + 9); doc.text(`Passkey: ${entry.signCrew.username}`, sigX + 80.5, sigY + 9);
doc.text(crewDate, sigX + 80.5, sigY + 13.5); doc.text(crewDate, sigX + 80.5, sigY + 13.5);
} else if (isSignatureImage(entry.signCrew)) { } else if (isClassicSignature(entry.signCrew)) {
doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14) doc.setFont('Helvetica', 'normal');
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
doc.text(entry.signCrew.username, sigX + 80.5, sigY + 9);
doc.text(crewDate, sigX + 80.5, sigY + 13.5);
if (isSignatureImage(entry.signCrew.payload)) {
doc.addImage(entry.signCrew.payload, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
}
} else if (isSignatureImage(getSignaturePayload(entry.signCrew))) {
doc.addImage(getSignaturePayload(entry.signCrew), 'PNG', sigX + 80.5, sigY + 6, 72, 14)
} else { } else {
doc.setFont('Helvetica', 'normal'); doc.setFont('Helvetica', 'normal');
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2); doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
+22 -2
View File
@@ -10,6 +10,7 @@ import {
parseEventDistanceNm, parseEventDistanceNm,
splitDistanceByPropulsion splitDistanceByPropulsion
} from '../utils/propulsionStats.js' } from '../utils/propulsionStats.js'
import { computeFuelPerMotorHour } from '../utils/fuelStats.js'
export type DistanceSource = 'gps' | 'events' | 'none' export type DistanceSource = 'gps' | 'events' | 'none'
@@ -27,6 +28,8 @@ export interface TravelDayStats {
sailDistanceNm: number sailDistanceNm: number
motorDistanceNm: number motorDistanceNm: number
unknownPropulsionNm: number unknownPropulsionNm: number
motorHours: number
fuelPerMotorHourL: number | null
hasGpsTrack: boolean hasGpsTrack: boolean
} }
@@ -59,12 +62,15 @@ export interface StatsTotals {
sailDistanceNm: number sailDistanceNm: number
motorDistanceNm: number motorDistanceNm: number
unknownPropulsionNm: number unknownPropulsionNm: number
totalMotorHours: number
totalFuelL: number totalFuelL: number
totalFreshwaterL: number totalFreshwaterL: number
avgDistancePerDayNm: number avgDistancePerDayNm: number
avgMotorHoursPerDay: number
avgFuelPerDayL: number avgFuelPerDayL: number
avgFreshwaterPerDayL: number avgFreshwaterPerDayL: number
fuelPerNmL: number | null fuelPerNmL: number | null
fuelPerMotorHourL: number | null
} }
const TRACK_COLORS = [ const TRACK_COLORS = [
@@ -102,6 +108,7 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0) const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0)
const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0) const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0)
const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 0) const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 0)
const totalMotorHours = days.reduce((sum, d) => sum + d.motorHours, 0)
const totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 0) const totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 0)
const totalFreshwaterL = days.reduce((sum, d) => sum + d.freshwaterConsumptionL, 0) const totalFreshwaterL = days.reduce((sum, d) => sum + d.freshwaterConsumptionL, 0)
@@ -112,10 +119,13 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
sailDistanceNm: Number(sailDistanceNm.toFixed(2)), sailDistanceNm: Number(sailDistanceNm.toFixed(2)),
motorDistanceNm: Number(motorDistanceNm.toFixed(2)), motorDistanceNm: Number(motorDistanceNm.toFixed(2)),
unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)), unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)),
totalMotorHours: Number(totalMotorHours.toFixed(1)),
totalFuelL: Number(totalFuelL.toFixed(1)), totalFuelL: Number(totalFuelL.toFixed(1)),
totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)), totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)),
avgDistancePerDayNm: avgDistancePerDayNm:
travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0, travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0,
avgMotorHoursPerDay:
travelDayCount > 0 ? Number((totalMotorHours / travelDayCount).toFixed(1)) : 0,
avgFuelPerDayL: avgFuelPerDayL:
travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0, travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0,
avgFreshwaterPerDayL: avgFreshwaterPerDayL:
@@ -123,7 +133,8 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
fuelPerNmL: fuelPerNmL:
totalDistanceNm > 0 && totalFuelL > 0 totalDistanceNm > 0 && totalFuelL > 0
? Number((totalFuelL / totalDistanceNm).toFixed(2)) ? Number((totalFuelL / totalDistanceNm).toFixed(2))
: null : null,
fuelPerMotorHourL: computeFuelPerMotorHour(totalFuelL, totalMotorHours)
} }
} }
@@ -180,6 +191,9 @@ async function loadTravelDaysForLogbook(
hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId)) hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId))
} }
const fuelConsumptionL = Number(payload.fuel?.consumption) || 0
const motorHours = Number(payload.motorHours) || 0
days.push({ days.push({
entryId: entry.payloadId, entryId: entry.payloadId,
logbookId, logbookId,
@@ -189,11 +203,13 @@ async function loadTravelDaysForLogbook(
destination: payload.destination || '', destination: payload.destination || '',
distanceNm, distanceNm,
distanceSource, distanceSource,
fuelConsumptionL: Number(payload.fuel?.consumption) || 0, fuelConsumptionL,
freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0, freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0,
sailDistanceNm: propulsion.sailDistanceNm, sailDistanceNm: propulsion.sailDistanceNm,
motorDistanceNm: propulsion.motorDistanceNm, motorDistanceNm: propulsion.motorDistanceNm,
unknownPropulsionNm: propulsion.unknownPropulsionNm, unknownPropulsionNm: propulsion.unknownPropulsionNm,
motorHours,
fuelPerMotorHourL: computeFuelPerMotorHour(fuelConsumptionL, motorHours),
hasGpsTrack hasGpsTrack
}) })
} }
@@ -249,3 +265,7 @@ export function formatNm(value: number): string {
export function formatLiters(value: number): string { export function formatLiters(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1) return Number.isInteger(value) ? String(value) : value.toFixed(1)
} }
export function formatHours(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
+13 -2
View File
@@ -11,5 +11,16 @@ export interface PasskeySignature {
clientVerified: boolean clientVerified: boolean
} }
/** Legacy: PNG data URL oder getippter Name */ /** Klassische Unterschrift mit Benutzer-Zuordnung (Crew) */
export type SignatureValue = string | PasskeySignature export interface ClassicSignature {
kind: 'classic'
version: 1
role: 'skipper' | 'crew'
userId: string
username: string
signedAt: string
payload: string
}
/** Legacy: PNG data URL oder getippter Name; oder strukturierte Signaturen */
export type SignatureValue = string | PasskeySignature | ClassicSignature
+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)
}
+49 -1
View File
@@ -17,6 +17,50 @@ export interface LogEventPayload {
remarks: string remarks: string
} }
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: timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw,
mgk: '',
rwk: '',
windPressure: '',
windDirection: '',
windStrength: '',
seaState: '',
weatherIcon: '',
current: '',
heel: '',
sailsOrMotor: '',
logReading: '',
distance: '',
gpsLat: '',
gpsLng: '',
remarks: ''
}
for (const key of LOG_EVENT_FIELDS) {
if (key === 'time') continue
normalized[key] = String(e[key] ?? '').trim()
}
return normalized
}
export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean {
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
}
/** Chronological order: earliest time first (HH:MM). */
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
}
export interface LogEntryPayloadInput { export interface LogEntryPayloadInput {
date: string date: string
dayOfTravel: string dayOfTravel: string
@@ -27,6 +71,7 @@ export interface LogEntryPayloadInput {
trackDistanceNm?: number trackDistanceNm?: number
trackSpeedMaxKn?: number trackSpeedMaxKn?: number
trackSpeedAvgKn?: number trackSpeedAvgKn?: number
motorHours?: number
events: LogEventPayload[] events: LogEventPayload[]
} }
@@ -38,12 +83,15 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
destination: input.destination.trim(), destination: input.destination.trim(),
freshwater: { ...input.freshwater }, freshwater: { ...input.freshwater },
fuel: { ...input.fuel }, fuel: { ...input.fuel },
events: input.events.map((e) => ({ ...e })) events: sortLogEventsByTime(input.events.map((e) => normalizeLogEvent(e)))
} }
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn
if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn
if (input.motorHours !== undefined && input.motorHours > 0) {
payload.motorHours = Number(input.motorHours.toFixed(2))
}
return payload return payload
} }
+57 -4
View File
@@ -1,8 +1,13 @@
import { hashEntryForSigning } from './entryCanonicalHash.js' import { hashEntryForSigning } from './entryCanonicalHash.js'
import type { PasskeySignature, SignatureValue } from '../types/signatures.js' import type { ClassicSignature, PasskeySignature, SignatureValue } from '../types/signatures.js'
export type SkipperSignStatus = 'none' | 'valid' | 'invalid' export type SkipperSignStatus = 'none' | 'valid' | 'invalid'
export interface SignatureAttribution {
username: string
signedAt: string
}
export function isSignatureImage(value: string | undefined | null): boolean { export function isSignatureImage(value: string | undefined | null): boolean {
return typeof value === 'string' && value.startsWith('data:image/') return typeof value === 'string' && value.startsWith('data:image/')
} }
@@ -16,9 +21,52 @@ export function isPasskeySignature(value: unknown): value is PasskeySignature {
) )
} }
export function isClassicSignature(value: unknown): value is ClassicSignature {
return (
typeof value === 'object' &&
value !== null &&
(value as ClassicSignature).kind === 'classic' &&
(value as ClassicSignature).version === 1
)
}
export function getSignaturePayload(value: SignatureValue | '' | undefined | null): string {
if (!value) return ''
if (isClassicSignature(value)) return value.payload
if (isPasskeySignature(value)) return ''
return value
}
export function getSignatureAttribution(value: SignatureValue | '' | undefined | null): SignatureAttribution | null {
if (!value || typeof value === 'string') return null
if (isPasskeySignature(value) || isClassicSignature(value)) {
return { username: value.username, signedAt: value.signedAt }
}
return null
}
export function createClassicSignature(input: {
role: 'skipper' | 'crew'
userId: string
username: string
signedAt: string
payload: string
}): ClassicSignature {
return {
kind: 'classic',
version: 1,
role: input.role,
userId: input.userId,
username: input.username,
signedAt: input.signedAt,
payload: input.payload
}
}
export function normalizeSignature(value: unknown): SignatureValue | undefined { export function normalizeSignature(value: unknown): SignatureValue | undefined {
if (value === null || value === undefined || value === '') return undefined if (value === null || value === undefined || value === '') return undefined
if (isPasskeySignature(value)) return value if (isPasskeySignature(value)) return value
if (isClassicSignature(value)) return value
if (typeof value === 'string') return value if (typeof value === 'string') return value
return undefined return undefined
} }
@@ -47,6 +95,7 @@ export async function getSkipperSignStatus(
export interface SignatureExportLabels { export interface SignatureExportLabels {
imagePlaceholder: string imagePlaceholder: string
passkeyLabel: (username: string, signedAt: string) => string passkeyLabel: (username: string, signedAt: string) => string
attributionLabel: (username: string, signedAt: string) => string
} }
export function formatSignatureForExport( export function formatSignatureForExport(
@@ -57,15 +106,19 @@ export function formatSignatureForExport(
if (isPasskeySignature(value)) { if (isPasskeySignature(value)) {
return labels.passkeyLabel(value.username, value.signedAt) return labels.passkeyLabel(value.username, value.signedAt)
} }
if (isClassicSignature(value)) {
return labels.attributionLabel(value.username, value.signedAt)
}
if (isSignatureImage(value)) return labels.imagePlaceholder if (isSignatureImage(value)) return labels.imagePlaceholder
return value return value
} }
export function serializeSignature(value: SignatureValue | '' | undefined): SignatureValue | undefined { export function serializeSignature(value: SignatureValue | '' | undefined): SignatureValue | undefined {
if (!value) return undefined if (!value) return undefined
if (isPasskeySignature(value)) return value if (isPasskeySignature(value) || isClassicSignature(value)) return value
if (isSignatureImage(value)) return value const payload = typeof value === 'string' ? value : getSignaturePayload(value)
const trimmed = value.trim() if (isSignatureImage(payload)) return payload
const trimmed = payload.trim()
return trimmed || undefined return trimmed || undefined
} }
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

+95 -33
View File
@@ -28,9 +28,11 @@
.page { .page {
width: 210mm; width: 210mm;
height: 297mm; height: 297mm;
padding: 14mm 16mm 12mm; max-height: 297mm;
padding: 12mm 15mm 10mm;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 5mm;
background: background:
radial-gradient(ellipse 120% 80% at 100% 0%, rgba(56, 189, 248, 0.12) 0%, transparent 55%), radial-gradient(ellipse 120% 80% at 100% 0%, rgba(56, 189, 248, 0.12) 0%, transparent 55%),
radial-gradient(ellipse 90% 60% at 0% 100%, rgba(134, 59, 255, 0.14) 0%, transparent 50%), radial-gradient(ellipse 90% 60% at 0% 100%, rgba(134, 59, 255, 0.14) 0%, transparent 50%),
@@ -52,20 +54,20 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5mm; gap: 5mm;
margin-bottom: 6mm; flex-shrink: 0;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.logo { .logo {
width: 14mm; width: 16mm;
height: 14mm; height: 16mm;
flex-shrink: 0; flex-shrink: 0;
object-fit: contain; object-fit: contain;
} }
.title-block h1 { .title-block h1 {
font-size: 22pt; font-size: 23pt;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: #f8fafc; color: #f8fafc;
@@ -73,7 +75,7 @@
} }
.title-block p { .title-block p {
font-size: 10.5pt; font-size: 12pt;
color: #94a3b8; color: #94a3b8;
margin-top: 1.5mm; margin-top: 1.5mm;
} }
@@ -83,19 +85,19 @@
align-self: flex-start; align-self: flex-start;
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b; color: #1e293b;
font-size: 9pt; font-size: 11pt;
font-weight: 800; font-weight: 800;
letter-spacing: 0.12em; letter-spacing: 0.12em;
padding: 2mm 4mm; padding: 2.5mm 4.5mm;
border-radius: 2mm; border-radius: 2mm;
text-transform: uppercase; text-transform: uppercase;
} }
.intro { .intro {
font-size: 10.5pt; font-size: 12pt;
line-height: 1.55; line-height: 1.5;
color: #cbd5e1; color: #cbd5e1;
margin-bottom: 6mm; flex-shrink: 0;
max-width: 95%; max-width: 95%;
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -105,11 +107,48 @@
color: #f8fafc; color: #f8fafc;
} }
.screenshots {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3mm;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.screenshot-card {
border-radius: 2.5mm;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(15, 23, 42, 0.55);
display: flex;
flex-direction: column;
min-width: 0;
}
.screenshot-card img {
width: 100%;
height: 50mm;
object-fit: contain;
object-position: top center;
display: block;
background: #0b1220;
}
.screenshot-caption {
font-size: 9pt;
color: #94a3b8;
text-align: center;
padding: 1.5mm 2mm;
line-height: 1.3;
flex-shrink: 0;
}
.features { .features {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 3mm 6mm; gap: 2.5mm 6mm;
margin-bottom: 6mm; flex-shrink: 0;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
@@ -118,7 +157,7 @@
display: flex; display: flex;
gap: 2.5mm; gap: 2.5mm;
align-items: flex-start; align-items: flex-start;
font-size: 9.5pt; font-size: 10.5pt;
line-height: 1.4; line-height: 1.4;
color: #e2e8f0; color: #e2e8f0;
} }
@@ -136,20 +175,20 @@
border-left: 3px solid #fbbf24; border-left: 3px solid #fbbf24;
border-radius: 3mm; border-radius: 3mm;
padding: 5mm 6mm; padding: 5mm 6mm;
margin-bottom: 6mm; flex-shrink: 0;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.beta-box h2 { .beta-box h2 {
font-size: 11pt; font-size: 12.5pt;
color: #fbbf24; color: #fbbf24;
margin-bottom: 2mm; margin-bottom: 2mm;
font-weight: 700; font-weight: 700;
} }
.beta-box p { .beta-box p {
font-size: 9.5pt; font-size: 10.5pt;
line-height: 1.5; line-height: 1.5;
color: #cbd5e1; color: #cbd5e1;
} }
@@ -157,12 +196,12 @@
.cta { .cta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8mm; gap: 7mm;
background: rgba(15, 23, 42, 0.6); background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.2); border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 4mm; border-radius: 4mm;
padding: 5mm 6mm; padding: 5mm 6mm;
margin-bottom: auto; flex-shrink: 0;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
@@ -183,16 +222,16 @@
} }
.cta-text h3 { .cta-text h3 {
font-size: 13pt; font-size: 14.5pt;
color: #38bdf8; color: #38bdf8;
font-weight: 700; font-weight: 700;
margin-bottom: 2mm; margin-bottom: 2mm;
} }
.cta-text p { .cta-text p {
font-size: 9pt; font-size: 11pt;
color: #94a3b8; color: #94a3b8;
line-height: 1.45; line-height: 1.5;
} }
.tags { .tags {
@@ -203,7 +242,7 @@
} }
.tag { .tag {
font-size: 7.5pt; font-size: 9.5pt;
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
@@ -216,8 +255,9 @@
footer { footer {
border-top: 1px solid rgba(148, 163, 184, 0.15); border-top: 1px solid rgba(148, 163, 184, 0.15);
padding-top: 3mm; padding-top: 3mm;
margin-top: 5mm; margin-top: auto;
font-size: 7.5pt; flex-shrink: 0;
font-size: 9.5pt;
line-height: 1.5; line-height: 1.5;
color: #64748b; color: #64748b;
position: relative; position: relative;
@@ -242,20 +282,42 @@
</header> </header>
<p class="intro"> <p class="intro">
Führe dein Bordlogbuch digital: Reisetage, GPS-Tracks, Crew und Schiffsdaten — Führe dein Bordlogbuch digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
<strong>End-to-End-verschlüsselt</strong>, als App installierbar und <strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und
<strong>auch offline</strong> auf See nutzbar. <strong>auch offline</strong> auf See nutzbar.
</p> </p>
<section class="features" aria-label="Funktionen"> <section class="features" aria-label="Funktionen">
<div class="feature"><span class="feature-icon"></span><span>Reisetage im nautischen Logbuch-Format (Hafen, Wetter, Tankstände)</span></div> <div class="feature"><span class="feature-icon"></span><span>Reisetage im nautischen Logbuch-Format (Hafen, Wetter, Besegelung, Crew, Tankstände)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Offline-fähige PWA — installierbar auf Smartphone &amp; Tablet</span></div> <div class="feature"><span class="feature-icon"></span><span>Offline-fähige PWA — läuft auf jedem Smartphone &amp; Tablet</span></div>
<div class="feature"><span class="feature-icon"></span><span>Passkey-Anmeldung &amp; clientseitige Verschlüsselung</span></div> <div class="feature"><span class="feature-icon"></span><span>Einfache passwortlose Passkey-Anmeldung</span></div>
<div class="feature"><span class="feature-icon"></span><span>GPS-Tracks (GPX/KML), Karte &amp; Streckenstatistik</span></div> <div class="feature"><span class="feature-icon"></span><span>Ende-zu-Ende Verschlüsselung</span></div>
<div class="feature"><span class="feature-icon"></span><span>GPS-Track Upload (GPX/KML) mit Kartendarstellung</span></div>
<div class="feature"><span class="feature-icon"></span><span>Streckenstatistik</span></div>
<div class="feature"><span class="feature-icon"></span><span>Foto-Anhänge pro Reisetag</span></div> <div class="feature"><span class="feature-icon"></span><span>Foto-Anhänge pro Reisetag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Foto-Anhänge für Skipper und Crew</span></div>
<div class="feature"><span class="feature-icon"></span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div> <div class="feature"><span class="feature-icon"></span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div>
<div class="feature"><span class="feature-icon"></span><span>PDF- &amp; CSV-Export, verschlüsseltes Backup</span></div> <div class="feature"><span class="feature-icon"></span><span>PDF- &amp; CSV-Export</span></div>
<div class="feature"><span class="feature-icon"></span><span>Mehrere Logbücher · Deutsch &amp; Englisch</span></div> <div class="feature"><span class="feature-icon"></span><span>Verschlüsseltes Backup &amp; Wiederherstellung</span></div>
<div class="feature"><span class="feature-icon"></span><span>Logbuch mit Freunden teilen</span></div>
<div class="feature"><span class="feature-icon"></span><span>Beliebig viele Schiffe und Logbücher</span></div>
<div class="feature"><span class="feature-icon"></span><span>Deutsch &amp; Englisch</span></div>
<div class="feature"><span class="feature-icon"></span><span>Crafted in Kiel.Sailing.City.</span></div>
</section>
<section class="screenshots" aria-label="App-Screenshots">
<figure class="screenshot-card">
<img src="assets/screenshot-login.png" alt="Anmeldung mit Passkey und Demo" />
<figcaption class="screenshot-caption">Anmeldung &amp; Passkey</figcaption>
</figure>
<figure class="screenshot-card">
<img src="assets/screenshot-logbook.png" alt="Logbuch-Journal mit Reisetagen" />
<figcaption class="screenshot-caption">Logbuch-Journal</figcaption>
</figure>
<figure class="screenshot-card">
<img src="assets/screenshot-vessel.png" alt="Schiffs-Stammdaten mit Yachtfoto" />
<figcaption class="screenshot-caption">Schiffsdaten</figcaption>
</figure>
</section> </section>
<section class="beta-box"> <section class="beta-box">
Binary file not shown.
+5 -2
View File
@@ -29,6 +29,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Demo Opened | Public-Demo unter `/demo` geöffnet (`DemoViewer.tsx`) | — | | Demo Opened | Public-Demo unter `/demo` geöffnet (`DemoViewer.tsx`) | — |
| Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — | | Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — |
| Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — | | Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — |
| Logbook Shared | Öffentlicher Freigabelink aktiviert (`SettingsForm.tsx`) | — |
| Public Link Opened | Freigabelink unter `/share` erfolgreich geladen (`ReadOnlyViewer.tsx`) | — |
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` | | PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — | | CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — | | CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
@@ -52,8 +54,9 @@ Empfohlene Goal-Ketten für Auswertung:
1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved 1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped) 2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
3. **Kollaboration:** Invite Generated → Invite Accepted 3. **Kollaboration:** Invite Generated → Invite Accepted
4. **Export:** Travel Day Saved → PDF Exported / CSV Exported 4. **Öffentliche Freigabe:** Logbook Shared → Public Link Opened
5. **Datensicherung:** Backup Exported → Backup Restored 5. **Export:** Travel Day Saved → PDF Exported / CSV Exported
6. **Datensicherung:** Backup Exported → Backup Restored
## Entwicklung ## Entwicklung
+36 -13
View File
@@ -57,6 +57,13 @@ function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: stri
return access.isOwner || access.collaboration?.role === 'WRITE' return access.isOwner || access.collaboration?.role === 'WRITE'
} }
async function hasWriteCollaborators(logbookId: string): Promise<boolean> {
const count = await prisma.collaboration.count({
where: { logbookId, role: 'WRITE' }
})
return count > 0
}
async function getAllowCredentialsForRole( async function getAllowCredentialsForRole(
logbookId: string, logbookId: string,
role: 'skipper' | 'crew', role: 'skipper' | 'crew',
@@ -79,7 +86,16 @@ async function getAllowCredentialsForRole(
}) })
const userIds = collaborations.map((c) => c.userId) const userIds = collaborations.map((c) => c.userId)
if (userIds.length === 0) return [] if (userIds.length === 0) {
const credentials = await prisma.credential.findMany({
where: { userId: requestingUserId }
})
return credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key' as const,
transports: cred.transports as any[]
}))
}
const credentials = await prisma.credential.findMany({ const credentials = await prisma.credential.findMany({
where: { userId: { in: userIds } } where: { userId: { in: userIds } }
@@ -99,14 +115,7 @@ async function isAuthorizedSigner(
role: 'skipper' | 'crew' role: 'skipper' | 'crew'
): Promise<boolean> { ): Promise<boolean> {
if (role === 'skipper') { if (role === 'skipper') {
// Skipper signing: owner or WRITE collaborator (design §2.1), using their own passkey. return signerUserId === ownerUserId
if (signerUserId === ownerUserId) return true
const collaboration = await prisma.collaboration.findUnique({
where: {
logbookId_userId: { logbookId, userId: signerUserId }
}
})
return collaboration?.role === 'WRITE'
} }
const collaboration = await prisma.collaboration.findUnique({ const collaboration = await prisma.collaboration.findUnique({
@@ -114,7 +123,13 @@ async function isAuthorizedSigner(
logbookId_userId: { logbookId, userId: signerUserId } logbookId_userId: { logbookId, userId: signerUserId }
} }
}) })
return collaboration?.role === 'WRITE' if (collaboration?.role === 'WRITE') return true
if (signerUserId === ownerUserId) {
return !(await hasWriteCollaborators(logbookId))
}
return false
} }
router.post('/options', async (req: any, res) => { router.post('/options', async (req: any, res) => {
@@ -138,6 +153,16 @@ router.post('/options', async (req: any, res) => {
return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' }) return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' })
} }
const authorized = await isAuthorizedSigner(
logbookId,
access.logbook.userId,
req.userId,
role
)
if (!authorized) {
return res.status(403).json({ error: 'Forbidden: Signer not authorized for this role' })
}
const allowCredentials = await getAllowCredentialsForRole( const allowCredentials = await getAllowCredentialsForRole(
logbookId, logbookId,
role, role,
@@ -146,9 +171,7 @@ router.post('/options', async (req: any, res) => {
if (allowCredentials.length === 0) { if (allowCredentials.length === 0) {
return res.status(400).json({ return res.status(400).json({
error: role === 'crew' error: 'No passkey credentials found for signer'
? 'No write collaborators with passkeys found'
: 'No passkey credentials found for signer'
}) })
} }
+11
View File
@@ -121,6 +121,17 @@ router.post('/push', async (req: any, res) => {
continue continue
} }
if (!isOwner && (type === 'yacht' || (type === 'crew' && payloadId === 'skipper'))) {
results.push({
payloadId,
status: 'error',
error: type === 'yacht'
? 'Forbidden: Only owner can modify vessel data'
: 'Forbidden: Only owner can modify skipper profile'
})
continue
}
if (action === 'delete') { if (action === 'delete') {
if (type === 'yacht') { if (type === 'yacht') {
await prisma.yachtPayload.deleteMany({ where: { logbookId } }) await prisma.yachtPayload.deleteMany({ where: { logbookId } })