Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87b0fa7bde | |||
| d90f292a21 | |||
| 9e42f828a0 | |||
| 4197e77b1e | |||
| 1373c11de8 | |||
| 0bae3b29dc | |||
| 73e86d28b3 | |||
| ad4721e694 | |||
| 8037b3b63e | |||
| c4cd566da0 | |||
| 3a267905b0 | |||
| c856c2e903 | |||
| b3256d1685 | |||
| 23fc940324 | |||
| 25e1bdded3 | |||
| 6a61c9e06c | |||
| d3683ad6aa | |||
| ef5891ba3f | |||
| d25095bab3 | |||
| 0d16782001 | |||
| b7e2d470a9 | |||
| 520ba766a3 | |||
| c215cd8b15 | |||
| 27c780d2b8 | |||
| aa52948ddc | |||
| 49b4e7b9c3 |
+114
-8
@@ -8,6 +8,18 @@ body {
|
|||||||
color: var(--app-text);
|
color: var(--app-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
color: var(--app-input-text);
|
||||||
|
background: var(--app-icon-btn-bg);
|
||||||
|
border: 1px solid var(--app-icon-btn-border);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 135%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
#root:has(.auth-screen) {
|
#root:has(.auth-screen) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -1046,6 +1058,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
.profile-dl-row dd {
|
.profile-dl-row dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
color: var(--app-text);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
@@ -1059,8 +1072,6 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
|
|
||||||
.profile-user-id code {
|
.profile-user-id code {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: rgba(148, 163, 184, 0.08);
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
@@ -1127,8 +1138,8 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(148, 163, 184, 0.06);
|
background: var(--app-icon-btn-bg);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
border: 1px solid var(--app-icon-btn-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-passkey-main {
|
.profile-passkey-main {
|
||||||
@@ -1241,6 +1252,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
display: block;
|
display: block;
|
||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
color: var(--app-input-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-passkey-transports {
|
.profile-passkey-transports {
|
||||||
@@ -3056,6 +3068,98 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider {
|
||||||
|
--tank-slider-track-h: 10px;
|
||||||
|
--tank-slider-thumb: 26px;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--tank-slider-thumb);
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
accent-color: #4ade80;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider::-webkit-slider-runnable-track {
|
||||||
|
height: var(--tank-slider-track-h);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: var(--tank-slider-thumb);
|
||||||
|
height: var(--tank-slider-thumb);
|
||||||
|
margin-top: calc((var(--tank-slider-track-h) - var(--tank-slider-thumb)) / 2);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4ade80;
|
||||||
|
border: 2px solid rgba(15, 23, 42, 0.85);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider::-moz-range-track {
|
||||||
|
height: var(--tank-slider-track-h);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider::-moz-range-thumb {
|
||||||
|
width: var(--tank-slider-thumb);
|
||||||
|
height: var(--tank-slider-thumb);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4ade80;
|
||||||
|
border: 2px solid rgba(15, 23, 42, 0.85);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.tank-liter-input .tank-liter-slider {
|
||||||
|
--tank-slider-track-h: 12px;
|
||||||
|
--tank-slider-thumb: 32px;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-tanks-section {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-tanks-section h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-tanks-help {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-tanks-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* GPS Track Upload & Map Styling */
|
/* GPS Track Upload & Map Styling */
|
||||||
.track-upload-zone {
|
.track-upload-zone {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4264,10 +4368,12 @@ body.app-tour-active .app-tour-target-active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-tour-tooltip:not(.centered) {
|
.app-tour-tooltip:not(.centered) {
|
||||||
left: max(16px, env(safe-area-inset-left, 0px));
|
left: 50%;
|
||||||
right: max(16px, env(safe-area-inset-right, 0px));
|
transform: translateX(-50%);
|
||||||
width: auto;
|
}
|
||||||
max-width: none;
|
|
||||||
|
.app-tour-tooltip:not(.centered).app-tour-tooltip--anchored {
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-tour-tooltip.centered {
|
.app-tour-tooltip.centered {
|
||||||
|
|||||||
+86
-22
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import { DialogProvider } from './components/ModalDialog.tsx'
|
import { DialogProvider } from './components/ModalDialog.tsx'
|
||||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
resolveColorScheme,
|
resolveColorScheme,
|
||||||
subscribeToSystemColorScheme
|
subscribeToSystemColorScheme
|
||||||
} from './services/appearance.js'
|
} from './services/appearance.js'
|
||||||
|
import { syncAppearancePrefs } from './services/appearancePrefs.js'
|
||||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||||
import DemoViewer from './components/DemoViewer.tsx'
|
import DemoViewer from './components/DemoViewer.tsx'
|
||||||
@@ -46,7 +47,7 @@ import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
|||||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
getStoredDemoFirstEntryId,
|
resolveTourLogbookContext,
|
||||||
seedDemoLogbookIfNeeded
|
seedDemoLogbookIfNeeded
|
||||||
} from './services/demoLogbook.js'
|
} from './services/demoLogbook.js'
|
||||||
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
||||||
@@ -57,7 +58,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 { confirmLeave } = useUnsavedChangesContext()
|
||||||
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
const { registerNavigation, registerDemoTourContext, 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)
|
||||||
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
||||||
@@ -69,6 +70,12 @@ function App() {
|
|||||||
const [isSyncing, setIsSyncing] = useState(false)
|
const [isSyncing, setIsSyncing] = useState(false)
|
||||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||||
const [showUserProfile, setShowUserProfile] = useState(false)
|
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||||
|
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
||||||
|
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
||||||
|
id: activeLogbookId,
|
||||||
|
title: activeLogbookTitle
|
||||||
|
})
|
||||||
|
activeLogbookRef.current = { id: activeLogbookId, title: activeLogbookTitle }
|
||||||
|
|
||||||
// Viewer mode for read-only shared links
|
// Viewer mode for read-only shared links
|
||||||
const [isViewerMode, setIsViewerMode] = useState(false)
|
const [isViewerMode, setIsViewerMode] = useState(false)
|
||||||
@@ -145,6 +152,13 @@ function App() {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return
|
||||||
|
const userId = localStorage.getItem('active_userid')
|
||||||
|
if (!userId) return
|
||||||
|
void syncAppearancePrefs(userId)
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
setOnline(true)
|
setOnline(true)
|
||||||
@@ -309,28 +323,66 @@ function App() {
|
|||||||
setIsAcceptingInvite(false)
|
setIsAcceptingInvite(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
const selectLogbook = useCallback((id: string, title: string) => {
|
||||||
registerNavigation({
|
|
||||||
setActiveTab,
|
|
||||||
setSelectedEntryId: setTourSelectedEntryId,
|
|
||||||
setFeedbackOpen: setTourFeedbackOpen
|
|
||||||
})
|
|
||||||
}, [registerNavigation])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAuthenticated && activeLogbookId) {
|
|
||||||
setDemoHighlightEntryId(getStoredDemoFirstEntryId())
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, activeLogbookId])
|
|
||||||
|
|
||||||
const selectLogbook = (id: string, title: string) => {
|
|
||||||
setActiveLogbookId(id)
|
setActiveLogbookId(id)
|
||||||
setActiveLogbookTitle(title)
|
setActiveLogbookTitle(title)
|
||||||
setActiveTab('logs')
|
setActiveTab('logs')
|
||||||
setTourSelectedEntryId(null)
|
setTourSelectedEntryId(null)
|
||||||
localStorage.setItem('active_logbook_id', id)
|
localStorage.setItem('active_logbook_id', id)
|
||||||
localStorage.setItem('active_logbook_title', title)
|
localStorage.setItem('active_logbook_title', title)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
|
const ensureTourLogbookOpen = useCallback(async () => {
|
||||||
|
const ctx = await resolveTourLogbookContext(tourLogbookRef.current?.id)
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
if (activeLogbookRef.current.id !== ctx.logbookId) {
|
||||||
|
selectLogbook(ctx.logbookId, ctx.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.firstEntryId) {
|
||||||
|
setDemoHighlightEntryId(ctx.firstEntryId)
|
||||||
|
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
|
||||||
|
}
|
||||||
|
}, [registerDemoTourContext, selectLogbook])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerNavigation({
|
||||||
|
setActiveTab,
|
||||||
|
setSelectedEntryId: setTourSelectedEntryId,
|
||||||
|
setFeedbackOpen: setTourFeedbackOpen,
|
||||||
|
setProfileOpen: setShowUserProfile,
|
||||||
|
ensureLogbookForTour: ensureTourLogbookOpen,
|
||||||
|
setLogbookActive: (active) => {
|
||||||
|
if (active) {
|
||||||
|
void ensureTourLogbookOpen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, title } = activeLogbookRef.current
|
||||||
|
if (id && title) {
|
||||||
|
tourLogbookRef.current = { id, title }
|
||||||
|
}
|
||||||
|
setActiveLogbookId(null)
|
||||||
|
setActiveLogbookTitle(null)
|
||||||
|
setTourSelectedEntryId(null)
|
||||||
|
localStorage.removeItem('active_logbook_id')
|
||||||
|
localStorage.removeItem('active_logbook_title')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [ensureTourLogbookOpen, registerNavigation])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated || !activeLogbookId) return
|
||||||
|
void (async () => {
|
||||||
|
const ctx = await resolveTourLogbookContext()
|
||||||
|
if (!ctx || ctx.logbookId !== activeLogbookId) return
|
||||||
|
if (ctx.firstEntryId) {
|
||||||
|
setDemoHighlightEntryId(ctx.firstEntryId)
|
||||||
|
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [isAuthenticated, activeLogbookId, registerDemoTourContext])
|
||||||
|
|
||||||
const openLogbookById = useCallback(
|
const openLogbookById = useCallback(
|
||||||
async (logbookId: string) => {
|
async (logbookId: string) => {
|
||||||
@@ -346,7 +398,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
selectLogbook(logbookId, `${logbookId.slice(0, 8)}…`)
|
selectLogbook(logbookId, `${logbookId.slice(0, 8)}…`)
|
||||||
},
|
},
|
||||||
[]
|
[selectLogbook]
|
||||||
)
|
)
|
||||||
|
|
||||||
const consumePendingPushLogbook = useCallback(() => {
|
const consumePendingPushLogbook = useCallback(() => {
|
||||||
@@ -398,8 +450,20 @@ function App() {
|
|||||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||||
if (savedLogbookId && savedLogbookTitle) {
|
if (savedLogbookId && savedLogbookTitle) {
|
||||||
setActiveLogbookId(savedLogbookId)
|
try {
|
||||||
setActiveLogbookTitle(savedLogbookTitle)
|
const books = await fetchLogbooks()
|
||||||
|
const match = books.find((b) => b.id === savedLogbookId)
|
||||||
|
if (match) {
|
||||||
|
setActiveLogbookId(match.id)
|
||||||
|
setActiveLogbookTitle(match.title)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('active_logbook_id')
|
||||||
|
localStorage.removeItem('active_logbook_title')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setActiveLogbookId(savedLogbookId)
|
||||||
|
setActiveLogbookTitle(savedLogbookTitle)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
consumePendingPushLogbook()
|
consumePendingPushLogbook()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { X, ChevronLeft, ChevronRight } from 'lucide-react'
|
|||||||
import {
|
import {
|
||||||
getTourStepCopy,
|
getTourStepCopy,
|
||||||
getTourTargetSelector,
|
getTourTargetSelector,
|
||||||
|
getTourTargetRetryDelay,
|
||||||
isCenteredTourStep,
|
isCenteredTourStep,
|
||||||
useAppTour
|
useAppTour
|
||||||
} from '../context/AppTourContext.tsx'
|
} from '../context/AppTourContext.tsx'
|
||||||
@@ -17,6 +18,20 @@ interface SpotlightRect {
|
|||||||
|
|
||||||
const TOOLTIP_EDGE_MARGIN = 16
|
const TOOLTIP_EDGE_MARGIN = 16
|
||||||
const TOOLTIP_ESTIMATED_HEIGHT = 240
|
const TOOLTIP_ESTIMATED_HEIGHT = 240
|
||||||
|
const TOOLTIP_WIDTH = 420
|
||||||
|
const TARGET_VIEWPORT_MARGIN = 24
|
||||||
|
|
||||||
|
function clampTooltipTop(preferred: number): number {
|
||||||
|
const maxTop = window.innerHeight - TOOLTIP_EDGE_MARGIN - TOOLTIP_ESTIMATED_HEIGHT
|
||||||
|
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(preferred, maxTop))
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTooltipLeft(spotlight: SpotlightRect): number {
|
||||||
|
const tooltipWidth = Math.min(TOOLTIP_WIDTH, window.innerWidth - TOOLTIP_EDGE_MARGIN * 2)
|
||||||
|
const ideal = spotlight.left + spotlight.width / 2 - tooltipWidth / 2
|
||||||
|
const maxLeft = window.innerWidth - TOOLTIP_EDGE_MARGIN - tooltipWidth
|
||||||
|
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(ideal, maxLeft))
|
||||||
|
}
|
||||||
|
|
||||||
function buildCutoutClipPath(rect: SpotlightRect): string {
|
function buildCutoutClipPath(rect: SpotlightRect): string {
|
||||||
const right = rect.left + rect.width
|
const right = rect.left + rect.width
|
||||||
@@ -28,20 +43,36 @@ function computeTooltipTop(spotlight: SpotlightRect): number {
|
|||||||
const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
|
const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
|
||||||
const below = spotlight.top + spotlight.height + 12
|
const below = spotlight.top + spotlight.height + 12
|
||||||
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
|
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
|
||||||
return below
|
return clampTooltipTop(below)
|
||||||
}
|
}
|
||||||
|
|
||||||
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
|
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
|
||||||
if (above >= TOOLTIP_EDGE_MARGIN) {
|
if (above >= TOOLTIP_EDGE_MARGIN) {
|
||||||
return above
|
return clampTooltipTop(above)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(
|
return clampTooltipTop(below)
|
||||||
TOOLTIP_EDGE_MARGIN,
|
}
|
||||||
Math.min(below, viewportBottom - TOOLTIP_ESTIMATED_HEIGHT)
|
|
||||||
|
function isTargetVisibleInViewport(rect: DOMRect): boolean {
|
||||||
|
return (
|
||||||
|
rect.top >= TARGET_VIEWPORT_MARGIN &&
|
||||||
|
rect.bottom <= window.innerHeight - TARGET_VIEWPORT_MARGIN
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function measureSpotlight(el: Element): SpotlightRect | null {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) return null
|
||||||
|
const padding = 8
|
||||||
|
return {
|
||||||
|
top: Math.max(8, rect.top - padding),
|
||||||
|
left: Math.max(8, rect.left - padding),
|
||||||
|
width: rect.width + padding * 2,
|
||||||
|
height: rect.height + padding * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppTourOverlay() {
|
export default function AppTourOverlay() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const {
|
const {
|
||||||
@@ -50,6 +81,7 @@ export default function AppTourOverlay() {
|
|||||||
currentStepId,
|
currentStepId,
|
||||||
currentStepIndex,
|
currentStepIndex,
|
||||||
totalSteps,
|
totalSteps,
|
||||||
|
layoutTick,
|
||||||
nextStep,
|
nextStep,
|
||||||
prevStep,
|
prevStep,
|
||||||
skipTour
|
skipTour
|
||||||
@@ -65,7 +97,10 @@ export default function AppTourOverlay() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
const updateSpotlight = () => {
|
const updateSpotlight = () => {
|
||||||
|
if (cancelled) return
|
||||||
const selector = getTourTargetSelector(currentStepId)
|
const selector = getTourTargetSelector(currentStepId)
|
||||||
if (!selector) {
|
if (!selector) {
|
||||||
setSpotlight(null)
|
setSpotlight(null)
|
||||||
@@ -76,27 +111,38 @@ export default function AppTourOverlay() {
|
|||||||
setSpotlight(null)
|
setSpotlight(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
const padding = 8
|
if (!isTargetVisibleInViewport(rect)) {
|
||||||
setSpotlight({
|
el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' })
|
||||||
top: Math.max(8, rect.top - padding),
|
window.requestAnimationFrame(() => {
|
||||||
left: Math.max(8, rect.left - padding),
|
if (cancelled) return
|
||||||
width: rect.width + padding * 2,
|
const next = measureSpotlight(el)
|
||||||
height: rect.height + padding * 2
|
setSpotlight(next)
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpotlight(measureSpotlight(el))
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSpotlight()
|
updateSpotlight()
|
||||||
window.addEventListener('resize', updateSpotlight)
|
window.addEventListener('resize', updateSpotlight)
|
||||||
window.addEventListener('scroll', updateSpotlight, true)
|
window.addEventListener('scroll', updateSpotlight, true)
|
||||||
const timer = window.setTimeout(updateSpotlight, 120)
|
|
||||||
|
const retryDelays =
|
||||||
|
currentStepId === 'entry_track'
|
||||||
|
? [400, 700, 1100, 1600]
|
||||||
|
: [getTourTargetRetryDelay(currentStepId), 120, 280, 480]
|
||||||
|
const timers = retryDelays.map((delay) => window.setTimeout(updateSpotlight, delay))
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timer)
|
cancelled = true
|
||||||
|
for (const timer of timers) window.clearTimeout(timer)
|
||||||
window.removeEventListener('resize', updateSpotlight)
|
window.removeEventListener('resize', updateSpotlight)
|
||||||
window.removeEventListener('scroll', updateSpotlight, true)
|
window.removeEventListener('scroll', updateSpotlight, true)
|
||||||
}
|
}
|
||||||
}, [currentStepId, isActive])
|
}, [currentStepId, isActive, layoutTick])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive) return
|
if (!isActive) return
|
||||||
@@ -132,9 +178,17 @@ export default function AppTourOverlay() {
|
|||||||
const tooltipStyle = centered
|
const tooltipStyle = centered
|
||||||
? undefined
|
? undefined
|
||||||
: spotlight
|
: spotlight
|
||||||
? { top: computeTooltipTop(spotlight) }
|
? { top: computeTooltipTop(spotlight), left: computeTooltipLeft(spotlight) }
|
||||||
: { top: '20%' }
|
: { top: '20%' }
|
||||||
|
|
||||||
|
const tooltipClassName = [
|
||||||
|
'app-tour-tooltip',
|
||||||
|
centered ? 'centered' : '',
|
||||||
|
!centered && spotlight ? 'app-tour-tooltip--anchored' : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
const backdropStyle = spotlight && !centered
|
const backdropStyle = spotlight && !centered
|
||||||
? { clipPath: buildCutoutClipPath(spotlight) }
|
? { clipPath: buildCutoutClipPath(spotlight) }
|
||||||
: undefined
|
: undefined
|
||||||
@@ -159,7 +213,7 @@ export default function AppTourOverlay() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`app-tour-tooltip${centered ? ' centered' : ''}`} style={tooltipStyle}>
|
<div className={tooltipClassName} style={tooltipStyle}>
|
||||||
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
|
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -379,16 +379,37 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
{t('auth.recovery_fallback_warning')}
|
{t('auth.recovery_fallback_warning')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleRecoverySubmit} className="auth-form">
|
<form onSubmit={handleRecoverySubmit} className="auth-form" autoComplete="on">
|
||||||
<textarea
|
{(username.trim() || encryptedPayloads?.username) && (
|
||||||
className="input-textarea"
|
<input
|
||||||
placeholder={t('auth.recovery_placeholder')}
|
type="text"
|
||||||
value={recoveryInput}
|
name="username"
|
||||||
onChange={(e) => setRecoveryInput(e.target.value)}
|
autoComplete="username"
|
||||||
disabled={loading}
|
value={username.trim() || encryptedPayloads?.username || ''}
|
||||||
rows={3}
|
readOnly
|
||||||
required
|
tabIndex={-1}
|
||||||
/>
|
aria-hidden="true"
|
||||||
|
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
|
||||||
|
{t('auth.enter_recovery')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="recovery-key"
|
||||||
|
name="recovery-key"
|
||||||
|
type="password"
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('auth.recovery_placeholder')}
|
||||||
|
value={recoveryInput}
|
||||||
|
onChange={(e) => setRecoveryInput(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="auth-error">{error}</div>}
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
registerNavigation({
|
registerNavigation({
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
setSelectedEntryId: setTourSelectedEntryId,
|
setSelectedEntryId: setTourSelectedEntryId,
|
||||||
setFeedbackOpen: () => {}
|
setFeedbackOpen: () => {},
|
||||||
|
setLogbookActive: () => {},
|
||||||
|
setProfileOpen: () => {}
|
||||||
})
|
})
|
||||||
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
|
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
|
||||||
|
|
||||||
|
|||||||
@@ -344,15 +344,36 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
<h2>{t('auth.enter_recovery')}</h2>
|
<h2>{t('auth.enter_recovery')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
|
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
|
||||||
<form onSubmit={handleRecoverySubmit}>
|
<form onSubmit={handleRecoverySubmit} autoComplete="on">
|
||||||
<textarea
|
{(username.trim() || encryptedPayloads?.username) && (
|
||||||
className="input-text"
|
<input
|
||||||
placeholder={t('auth.recovery_placeholder')}
|
type="text"
|
||||||
value={recoveryInput}
|
name="username"
|
||||||
onChange={(e) => setRecoveryInput(e.target.value)}
|
autoComplete="username"
|
||||||
rows={3}
|
value={username.trim() || encryptedPayloads?.username || ''}
|
||||||
required
|
readOnly
|
||||||
/>
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="invitation-recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
|
||||||
|
{t('auth.enter_recovery')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="invitation-recovery-key"
|
||||||
|
name="recovery-key"
|
||||||
|
type="password"
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('auth.recovery_placeholder')}
|
||||||
|
value={recoveryInput}
|
||||||
|
onChange={(e) => setRecoveryInput(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="auth-actions mt-4">
|
<div className="auth-actions mt-4">
|
||||||
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
|
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
|
||||||
{t('auth.back')}
|
{t('auth.back')}
|
||||||
|
|||||||
@@ -241,14 +241,15 @@ export default function LogEntriesList({
|
|||||||
|
|
||||||
decryptedEntries.sort(compareTravelDaysChronological)
|
decryptedEntries.sort(compareTravelDaysChronological)
|
||||||
const previousEntry = decryptedEntries.at(-1) ?? null
|
const previousEntry = decryptedEntries.at(-1) ?? null
|
||||||
let { freshwater, fuel, departure } = carryOverFromPreviousDay(previousEntry)
|
let { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
|
||||||
|
|
||||||
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, departure })) {
|
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, greywaterLevel, departure })) {
|
||||||
const confirmed = await showConfirm(
|
const confirmed = await showConfirm(
|
||||||
t('logs.carry_over_tanks_confirm', {
|
t('logs.carry_over_tanks_confirm', {
|
||||||
departure: departure || '—',
|
departure: departure || '—',
|
||||||
fw: formatTankLiters(freshwater.morning),
|
fw: formatTankLiters(freshwater.morning),
|
||||||
fuel: formatTankLiters(fuel.morning)
|
fuel: formatTankLiters(fuel.morning),
|
||||||
|
greywater: formatTankLiters(greywaterLevel)
|
||||||
}),
|
}),
|
||||||
t('logs.carry_over_tanks_title'),
|
t('logs.carry_over_tanks_title'),
|
||||||
t('logs.carry_over_tanks_yes'),
|
t('logs.carry_over_tanks_yes'),
|
||||||
@@ -257,6 +258,7 @@ export default function LogEntriesList({
|
|||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
freshwater = emptyTankLevels()
|
freshwater = emptyTankLevels()
|
||||||
fuel = emptyTankLevels()
|
fuel = emptyTankLevels()
|
||||||
|
greywaterLevel = 0
|
||||||
departure = ''
|
departure = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,6 +276,7 @@ export default function LogEntriesList({
|
|||||||
destination: '',
|
destination: '',
|
||||||
freshwater,
|
freshwater,
|
||||||
fuel,
|
fuel,
|
||||||
|
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||||
signSkipper: '',
|
signSkipper: '',
|
||||||
signCrew: '',
|
signCrew: '',
|
||||||
events: []
|
events: []
|
||||||
@@ -365,6 +368,11 @@ export default function LogEntriesList({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tourFirstEntryId =
|
||||||
|
highlightEntryId && entries.some((e) => e.id === highlightEntryId)
|
||||||
|
? highlightEntryId
|
||||||
|
: entries[0]?.id ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-card">
|
<div className="form-card">
|
||||||
<div className="section-title-bar mb-6">
|
<div className="section-title-bar mb-6">
|
||||||
@@ -402,7 +410,7 @@ export default function LogEntriesList({
|
|||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="logbook-card glass"
|
className="logbook-card glass"
|
||||||
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
|
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
|
||||||
onClick={() => setSelectedEntryId(item.id)}
|
onClick={() => setSelectedEntryId(item.id)}
|
||||||
>
|
>
|
||||||
<div className="card-icon">
|
<div className="card-icon">
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
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, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
||||||
import CourseDialInput from './CourseDialInput.tsx'
|
import CourseDialInput from './CourseDialInput.tsx'
|
||||||
import { degreesToCardinal } from '../utils/courseAngle.js'
|
import { degreesToCardinal } from '../utils/courseAngle.js'
|
||||||
@@ -42,6 +42,14 @@ import {
|
|||||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||||
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||||
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||||
|
import TankLiterInput from './TankLiterInput.tsx'
|
||||||
|
import {
|
||||||
|
computeEveningTankMaxLiters,
|
||||||
|
computeRefilledTankMaxLiters,
|
||||||
|
extractTankCapacitiesFromYacht,
|
||||||
|
formatTankLitersForInput,
|
||||||
|
type VesselTankCapacities
|
||||||
|
} from '../utils/tankCapacity.js'
|
||||||
|
|
||||||
function emptyTankLevels() {
|
function emptyTankLevels() {
|
||||||
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
||||||
@@ -50,6 +58,7 @@ function emptyTankLevels() {
|
|||||||
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
|
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
|
||||||
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
|
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
|
||||||
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
|
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
|
||||||
|
const gw = decrypted.greywater as { level?: number } | undefined
|
||||||
const trackDistance = decrypted.trackDistanceNm
|
const trackDistance = decrypted.trackDistanceNm
|
||||||
const trackSpeedMax = decrypted.trackSpeedMaxKn
|
const trackSpeedMax = decrypted.trackSpeedMaxKn
|
||||||
const trackSpeedAvg = decrypted.trackSpeedAvgKn
|
const trackSpeedAvg = decrypted.trackSpeedAvgKn
|
||||||
@@ -72,6 +81,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
|||||||
evening: fuel.evening || 0,
|
evening: fuel.evening || 0,
|
||||||
consumption: fuel.consumption ?? 0
|
consumption: fuel.consumption ?? 0
|
||||||
},
|
},
|
||||||
|
greywater: gw ? { level: gw.level || 0 } : undefined,
|
||||||
trackDistanceNm:
|
trackDistanceNm:
|
||||||
trackDistance != null && trackDistance !== ''
|
trackDistance != null && trackDistance !== ''
|
||||||
? parseFloat(String(trackDistance))
|
? parseFloat(String(trackDistance))
|
||||||
@@ -145,6 +155,9 @@ export default function LogEntryEditor({
|
|||||||
const [fuelEvening, setFuelEvening] = useState('0')
|
const [fuelEvening, setFuelEvening] = useState('0')
|
||||||
const [fuelConsumption, setFuelConsumption] = useState('0')
|
const [fuelConsumption, setFuelConsumption] = useState('0')
|
||||||
|
|
||||||
|
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
||||||
|
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
||||||
|
|
||||||
// Signatures
|
// Signatures
|
||||||
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||||
@@ -202,6 +215,7 @@ export default function LogEntryEditor({
|
|||||||
const contentReadyRef = useRef(false)
|
const contentReadyRef = useRef(false)
|
||||||
const lastSignatureAlertHashRef = useRef<string | null>(null)
|
const lastSignatureAlertHashRef = useRef<string | null>(null)
|
||||||
const skipCrewSignClearRef = useRef(false)
|
const skipCrewSignClearRef = useRef(false)
|
||||||
|
const entryHashSeqRef = useRef(0)
|
||||||
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
|
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
|
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
|
||||||
@@ -248,6 +262,7 @@ export default function LogEntryEditor({
|
|||||||
evening: parseFloat(fuelEvening) || 0,
|
evening: parseFloat(fuelEvening) || 0,
|
||||||
consumption: parseFloat(fuelConsumption) || 0
|
consumption: parseFloat(fuelConsumption) || 0
|
||||||
},
|
},
|
||||||
|
greywater: { level: parseFloat(greywaterLevel) || 0 },
|
||||||
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,
|
||||||
@@ -258,6 +273,7 @@ export default function LogEntryEditor({
|
|||||||
date, dayOfTravel, departure, destination,
|
date, dayOfTravel, departure, destination,
|
||||||
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
||||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||||
|
greywaterLevel,
|
||||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||||
events
|
events
|
||||||
])
|
])
|
||||||
@@ -267,6 +283,38 @@ export default function LogEntryEditor({
|
|||||||
[fuelConsumption, motorHours]
|
[fuelConsumption, motorHours]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tankCapacityTooltip = t('logs.tank_capacity_tooltip')
|
||||||
|
|
||||||
|
const fwRefilledMax = useMemo(
|
||||||
|
() => computeRefilledTankMaxLiters(fwMorning, tankCapacities.freshwaterCapacityL),
|
||||||
|
[fwMorning, tankCapacities.freshwaterCapacityL]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fwEveningMax = useMemo(
|
||||||
|
() =>
|
||||||
|
computeEveningTankMaxLiters(
|
||||||
|
fwMorning,
|
||||||
|
fwRefilled,
|
||||||
|
tankCapacities.freshwaterCapacityL
|
||||||
|
),
|
||||||
|
[fwMorning, fwRefilled, tankCapacities.freshwaterCapacityL]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fuelRefilledMax = useMemo(
|
||||||
|
() => computeRefilledTankMaxLiters(fuelMorning, tankCapacities.fuelCapacityL),
|
||||||
|
[fuelMorning, tankCapacities.fuelCapacityL]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fuelEveningMax = useMemo(
|
||||||
|
() =>
|
||||||
|
computeEveningTankMaxLiters(
|
||||||
|
fuelMorning,
|
||||||
|
fuelRefilled,
|
||||||
|
tankCapacities.fuelCapacityL
|
||||||
|
),
|
||||||
|
[fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL]
|
||||||
|
)
|
||||||
|
|
||||||
const currentFingerprint = useMemo(() => {
|
const currentFingerprint = useMemo(() => {
|
||||||
const payload = buildPayloadForSigning()
|
const payload = buildPayloadForSigning()
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
@@ -304,13 +352,7 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasPendingEventForm = useMemo(() => {
|
const hasPendingEventForm = useMemo(() => {
|
||||||
if (!evTime.trim()) return false
|
return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events)
|
||||||
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,
|
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
|
||||||
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
||||||
@@ -331,16 +373,27 @@ export default function LogEntryEditor({
|
|||||||
onBack()
|
onBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
|
const persistEntryToDb = useCallback(async (
|
||||||
|
options?: LogEvent[] | {
|
||||||
|
eventsOverride?: LogEvent[]
|
||||||
|
signSkipper?: SignatureValue | ''
|
||||||
|
signCrew?: SignatureValue | ''
|
||||||
|
}
|
||||||
|
) => {
|
||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
|
|
||||||
|
const normalized = Array.isArray(options) ? { eventsOverride: options } : (options ?? {})
|
||||||
|
const eventsOverride = normalized.eventsOverride
|
||||||
|
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
|
||||||
|
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
|
||||||
|
|
||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
const entryData = {
|
const entryData = {
|
||||||
...buildPayloadForSigning(eventsOverride),
|
...buildPayloadForSigning(eventsOverride),
|
||||||
signSkipper: normalizedSerializedSignature(signSkipper),
|
signSkipper: normalizedSerializedSignature(skipperToSave),
|
||||||
signCrew: normalizedSerializedSignature(signCrew)
|
signCrew: normalizedSerializedSignature(crewToSave)
|
||||||
}
|
}
|
||||||
|
|
||||||
const encrypted = await encryptJson(entryData, masterKey)
|
const encrypted = await encryptJson(entryData, masterKey)
|
||||||
@@ -368,9 +421,14 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
setSavedFingerprint(JSON.stringify({
|
setSavedFingerprint(JSON.stringify({
|
||||||
...buildPayloadForSigning(eventsOverride),
|
...buildPayloadForSigning(eventsOverride),
|
||||||
signSkipper: fingerprintSignature(signSkipper),
|
signSkipper: fingerprintSignature(skipperToSave),
|
||||||
signCrew: fingerprintSignature(signCrew)
|
signCrew: fingerprintSignature(crewToSave)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const hash = await hashEntryForSigning(buildPayloadForSigning(eventsOverride))
|
||||||
|
entryHashSeqRef.current += 1
|
||||||
|
setEntryHash(hash)
|
||||||
|
lockedContentHashRef.current = hasAnySignature(skipperToSave, crewToSave) ? hash : null
|
||||||
}, [
|
}, [
|
||||||
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
|
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
|
||||||
])
|
])
|
||||||
@@ -398,9 +456,11 @@ export default function LogEntryEditor({
|
|||||||
}, [logbookId])
|
}, [logbookId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const seq = ++entryHashSeqRef.current
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
|
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
|
||||||
if (!cancelled) setEntryHash(hash)
|
if (cancelled || seq !== entryHashSeqRef.current) return
|
||||||
|
setEntryHash(hash)
|
||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [buildPayloadForSigning])
|
}, [buildPayloadForSigning])
|
||||||
@@ -471,6 +531,7 @@ export default function LogEntryEditor({
|
|||||||
role: 'skipper'
|
role: 'skipper'
|
||||||
})
|
})
|
||||||
setSignSkipper(signature)
|
setSignSkipper(signature)
|
||||||
|
entryHashSeqRef.current += 1
|
||||||
setEntryHash(hash)
|
setEntryHash(hash)
|
||||||
lockedContentHashRef.current = hash
|
lockedContentHashRef.current = hash
|
||||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||||
@@ -489,6 +550,7 @@ export default function LogEntryEditor({
|
|||||||
role: 'crew'
|
role: 'crew'
|
||||||
})
|
})
|
||||||
setSignCrew(signature)
|
setSignCrew(signature)
|
||||||
|
entryHashSeqRef.current += 1
|
||||||
setEntryHash(hash)
|
setEntryHash(hash)
|
||||||
lockedContentHashRef.current = hash
|
lockedContentHashRef.current = hash
|
||||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
|
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
|
||||||
@@ -512,11 +574,59 @@ export default function LogEntryEditor({
|
|||||||
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
||||||
}, [fuelMorning, fuelRefilled, fuelEvening])
|
}, [fuelMorning, fuelRefilled, fuelEvening])
|
||||||
|
|
||||||
// Load Yacht Sails
|
const fwRefilledNoCapacity =
|
||||||
|
(tankCapacities.freshwaterCapacityL ?? 0) > 0 && fwRefilledMax == null
|
||||||
|
const fuelRefilledNoCapacity =
|
||||||
|
(tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadYachtSails() {
|
const refilled = parseFloat(fwRefilled) || 0
|
||||||
if (readOnly && preloadedYacht?.sails) {
|
if (fwRefilledMax == null) {
|
||||||
setYachtSails(preloadedYacht.sails)
|
if (fwRefilledNoCapacity && refilled > 0) {
|
||||||
|
setFwRefilled(formatTankLitersForInput(0))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (refilled > fwRefilledMax) {
|
||||||
|
setFwRefilled(formatTankLitersForInput(fwRefilledMax))
|
||||||
|
}
|
||||||
|
}, [fwRefilledMax, fwRefilled, fwRefilledNoCapacity])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fwEveningMax == null) return
|
||||||
|
const evening = parseFloat(fwEvening) || 0
|
||||||
|
if (evening > fwEveningMax) {
|
||||||
|
setFwEvening(formatTankLitersForInput(fwEveningMax))
|
||||||
|
}
|
||||||
|
}, [fwEveningMax, fwEvening])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const refilled = parseFloat(fuelRefilled) || 0
|
||||||
|
if (fuelRefilledMax == null) {
|
||||||
|
if (fuelRefilledNoCapacity && refilled > 0) {
|
||||||
|
setFuelRefilled(formatTankLitersForInput(0))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (refilled > fuelRefilledMax) {
|
||||||
|
setFuelRefilled(formatTankLitersForInput(fuelRefilledMax))
|
||||||
|
}
|
||||||
|
}, [fuelRefilledMax, fuelRefilled, fuelRefilledNoCapacity])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fuelEveningMax == null) return
|
||||||
|
const evening = parseFloat(fuelEvening) || 0
|
||||||
|
if (evening > fuelEveningMax) {
|
||||||
|
setFuelEvening(formatTankLitersForInput(fuelEveningMax))
|
||||||
|
}
|
||||||
|
}, [fuelEveningMax, fuelEvening])
|
||||||
|
|
||||||
|
// Load yacht sails and tank capacities
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadYachtMeta() {
|
||||||
|
if (readOnly && preloadedYacht) {
|
||||||
|
if (preloadedYacht.sails) setYachtSails(preloadedYacht.sails)
|
||||||
|
setTankCapacities(extractTankCapacitiesFromYacht(preloadedYacht))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -526,16 +636,19 @@ export default function LogEntryEditor({
|
|||||||
const yacht = await db.yachts.get(logbookId)
|
const yacht = await db.yachts.get(logbookId)
|
||||||
if (yacht) {
|
if (yacht) {
|
||||||
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
|
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
|
||||||
if (decrypted && decrypted.sails && Array.isArray(decrypted.sails)) {
|
if (decrypted) {
|
||||||
setYachtSails(decrypted.sails)
|
if (decrypted.sails && Array.isArray(decrypted.sails)) {
|
||||||
|
setYachtSails(decrypted.sails)
|
||||||
|
}
|
||||||
|
setTankCapacities(extractTankCapacitiesFromYacht(decrypted))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load yacht sails in editor:', err)
|
console.error('Failed to load yacht meta in editor:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadYachtSails()
|
loadYachtMeta()
|
||||||
}, [logbookId, preloadedYacht])
|
}, [logbookId, preloadedYacht, readOnly])
|
||||||
|
|
||||||
// Load entry details
|
// Load entry details
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -565,6 +678,11 @@ export default function LogEntryEditor({
|
|||||||
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
||||||
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
|
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
|
||||||
}
|
}
|
||||||
|
if (preloadedEntry.greywater) {
|
||||||
|
setGreywaterLevel(String(preloadedEntry.greywater.level || 0))
|
||||||
|
} else {
|
||||||
|
setGreywaterLevel('0')
|
||||||
|
}
|
||||||
|
|
||||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||||
@@ -598,6 +716,11 @@ export default function LogEntryEditor({
|
|||||||
setFuelEvening(String(decrypted.fuel.evening || 0))
|
setFuelEvening(String(decrypted.fuel.evening || 0))
|
||||||
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
|
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
|
||||||
}
|
}
|
||||||
|
if (decrypted.greywater) {
|
||||||
|
setGreywaterLevel(String(decrypted.greywater.level || 0))
|
||||||
|
} else {
|
||||||
|
setGreywaterLevel('0')
|
||||||
|
}
|
||||||
|
|
||||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||||
@@ -921,10 +1044,23 @@ export default function LogEntryEditor({
|
|||||||
setEvLocationName('')
|
setEvLocationName('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveSignaturesAfterContentChange = (skipperOnly = false) => {
|
||||||
|
const hadSkipper = !!signSkipper
|
||||||
|
const hadCrew = !!signCrew
|
||||||
|
const cleared = hadSkipper || (hadCrew && !skipperOnly)
|
||||||
|
skipCrewSignClearRef.current = skipperOnly
|
||||||
|
const nextSkipper: SignatureValue | '' = hadSkipper ? '' : signSkipper
|
||||||
|
const nextCrew: SignatureValue | '' = hadCrew && !skipperOnly ? '' : signCrew
|
||||||
|
if (cleared) {
|
||||||
|
if (hadSkipper) setSignSkipper('')
|
||||||
|
if (hadCrew && !skipperOnly) setSignCrew('')
|
||||||
|
lockedContentHashRef.current = null
|
||||||
|
}
|
||||||
|
return { signSkipper: nextSkipper, signCrew: nextCrew, cleared }
|
||||||
|
}
|
||||||
|
|
||||||
const markSkipperSignatureClearedForEventChange = () => {
|
const markSkipperSignatureClearedForEventChange = () => {
|
||||||
if (!signSkipper) return
|
resolveSignaturesAfterContentChange(true)
|
||||||
skipCrewSignClearRef.current = true
|
|
||||||
setSignSkipper('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditEvent = (index: number) => {
|
const handleEditEvent = (index: number) => {
|
||||||
@@ -1014,11 +1150,20 @@ export default function LogEntryEditor({
|
|||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
|
|
||||||
let eventsToSave = events
|
let eventsToSave = events
|
||||||
|
let signaturesForSave: { signSkipper: SignatureValue | ''; signCrew: SignatureValue | '' } | undefined
|
||||||
|
|
||||||
if (hasPendingEventForm) {
|
if (hasPendingEventForm) {
|
||||||
const isEdit = editingEventIndex !== null
|
const isEdit = editingEventIndex !== null
|
||||||
if (isEdit && signSkipper) {
|
const resolved = resolveSignaturesAfterContentChange(isEdit)
|
||||||
markSkipperSignatureClearedForEventChange()
|
signaturesForSave = {
|
||||||
|
signSkipper: resolved.signSkipper,
|
||||||
|
signCrew: resolved.signCrew
|
||||||
|
}
|
||||||
|
if (resolved.cleared) {
|
||||||
|
void showAlertRef.current(
|
||||||
|
isEdit ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'),
|
||||||
|
isEdit ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
eventsToSave = applyEventFormToEvents(buildEventFromForm())
|
eventsToSave = applyEventFormToEvents(buildEventFromForm())
|
||||||
setEvents(eventsToSave)
|
setEvents(eventsToSave)
|
||||||
@@ -1032,7 +1177,10 @@ export default function LogEntryEditor({
|
|||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await persistEntryToDb(eventsToSave)
|
await persistEntryToDb({
|
||||||
|
eventsOverride: eventsToSave,
|
||||||
|
...signaturesForSave
|
||||||
|
})
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||||
@@ -1170,41 +1318,35 @@ export default function LogEntryEditor({
|
|||||||
<h3>{t('logs.freshwater')}</h3>
|
<h3>{t('logs.freshwater')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="consumption-grid">
|
<div className="consumption-grid">
|
||||||
|
<TankLiterInput
|
||||||
|
id="fw-morning"
|
||||||
|
label={t('logs.morning')}
|
||||||
|
value={fwMorning}
|
||||||
|
onChange={setFwMorning}
|
||||||
|
maxLiters={tankCapacities.freshwaterCapacityL}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
|
<TankLiterInput
|
||||||
|
id="fw-refilled"
|
||||||
|
label={t('logs.refilled')}
|
||||||
|
value={fwRefilled}
|
||||||
|
onChange={setFwRefilled}
|
||||||
|
maxLiters={fwRefilledMax}
|
||||||
|
disabled={saving || readOnly || fwRefilledNoCapacity}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
|
<TankLiterInput
|
||||||
|
id="fw-evening"
|
||||||
|
label={t('logs.evening')}
|
||||||
|
value={fwEvening}
|
||||||
|
onChange={setFwEvening}
|
||||||
|
maxLiters={fwEveningMax}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.morning')}</label>
|
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fwMorning}
|
|
||||||
onChange={(e) => setFwMorning(e.target.value)}
|
|
||||||
disabled={saving || readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
|
||||||
<label>{t('logs.refilled')}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fwRefilled}
|
|
||||||
onChange={(e) => setFwRefilled(e.target.value)}
|
|
||||||
disabled={saving || readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
|
||||||
<label>{t('logs.evening')}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fwEvening}
|
|
||||||
onChange={(e) => setFwEvening(e.target.value)}
|
|
||||||
disabled={saving || readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
|
||||||
<label>{t('logs.consumption')} (L)</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input-text consumption-value"
|
className="input-text consumption-value"
|
||||||
@@ -1212,6 +1354,7 @@ export default function LogEntryEditor({
|
|||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-readonly="true"
|
aria-readonly="true"
|
||||||
|
title={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1224,41 +1367,35 @@ export default function LogEntryEditor({
|
|||||||
<h3>{t('logs.fuel')}</h3>
|
<h3>{t('logs.fuel')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="consumption-grid">
|
<div className="consumption-grid">
|
||||||
|
<TankLiterInput
|
||||||
|
id="fuel-morning"
|
||||||
|
label={t('logs.morning')}
|
||||||
|
value={fuelMorning}
|
||||||
|
onChange={setFuelMorning}
|
||||||
|
maxLiters={tankCapacities.fuelCapacityL}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
|
<TankLiterInput
|
||||||
|
id="fuel-refilled"
|
||||||
|
label={t('logs.refilled')}
|
||||||
|
value={fuelRefilled}
|
||||||
|
onChange={setFuelRefilled}
|
||||||
|
maxLiters={fuelRefilledMax}
|
||||||
|
disabled={saving || readOnly || fuelRefilledNoCapacity}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
|
<TankLiterInput
|
||||||
|
id="fuel-evening"
|
||||||
|
label={t('logs.evening')}
|
||||||
|
value={fuelEvening}
|
||||||
|
onChange={setFuelEvening}
|
||||||
|
maxLiters={fuelEveningMax}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.morning')}</label>
|
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fuelMorning}
|
|
||||||
onChange={(e) => setFuelMorning(e.target.value)}
|
|
||||||
disabled={saving || readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
|
||||||
<label>{t('logs.refilled')}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fuelRefilled}
|
|
||||||
onChange={(e) => setFuelRefilled(e.target.value)}
|
|
||||||
disabled={saving || readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
|
||||||
<label>{t('logs.evening')}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fuelEvening}
|
|
||||||
onChange={(e) => setFuelEvening(e.target.value)}
|
|
||||||
disabled={saving || readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
|
||||||
<label>{t('logs.consumption')} (L)</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input-text consumption-value"
|
className="input-text consumption-value"
|
||||||
@@ -1266,11 +1403,12 @@ export default function LogEntryEditor({
|
|||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-readonly="true"
|
aria-readonly="true"
|
||||||
|
title={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.fuel_per_motor_hour')}</label>
|
<label title={tankCapacityTooltip}>{t('logs.fuel_per_motor_hour')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input-text consumption-value"
|
className="input-text consumption-value"
|
||||||
@@ -1282,10 +1420,30 @@ export default function LogEntryEditor({
|
|||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-readonly="true"
|
aria-readonly="true"
|
||||||
|
title={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Greywater card */}
|
||||||
|
<div className="form-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<Compass size={20} className="form-icon" />
|
||||||
|
<h3>{t('logs.greywater')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="consumption-grid">
|
||||||
|
<TankLiterInput
|
||||||
|
id="greywater-level"
|
||||||
|
label={t('logs.greywater_level')}
|
||||||
|
value={greywaterLevel}
|
||||||
|
onChange={setGreywaterLevel}
|
||||||
|
maxLiters={tankCapacities.greywaterCapacityL}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 3: Event Journal Entries */}
|
{/* Section 3: Event Journal Entries */}
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
onClick={onOpenProfile}
|
onClick={onOpenProfile}
|
||||||
title={t('dashboard.open_profile', { name: username })}
|
title={t('dashboard.open_profile', { name: username })}
|
||||||
aria-label={t('dashboard.open_profile', { name: username })}
|
aria-label={t('dashboard.open_profile', { name: username })}
|
||||||
|
data-tour="nav-profile"
|
||||||
>
|
>
|
||||||
<User size={18} aria-hidden="true" />
|
<User size={18} aria-hidden="true" />
|
||||||
<span className="skipper-badge__name">{username}</span>
|
<span className="skipper-badge__name">{username}</span>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
|||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { apiFetch } from '../services/api.js'
|
import { apiFetch } from '../services/api.js'
|
||||||
|
import {
|
||||||
|
enableCollaboratorChangePush,
|
||||||
|
isCollaboratorPushActive,
|
||||||
|
isPushSupported
|
||||||
|
} from '../services/pushNotifications.js'
|
||||||
|
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||||
|
|
||||||
interface SettingsFormProps {
|
interface SettingsFormProps {
|
||||||
logbookId?: string | null
|
logbookId?: string | null
|
||||||
@@ -151,6 +157,43 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const promptPushAfterInviteCreated = async () => {
|
||||||
|
if (!isPushSupported()) return
|
||||||
|
if (await isCollaboratorPushActive()) return
|
||||||
|
|
||||||
|
const iosNeedsInstall = isIosDevice() && !isRunningStandalone()
|
||||||
|
|
||||||
|
if (iosNeedsInstall) {
|
||||||
|
await showAlert(
|
||||||
|
t('settings.invite_push_prompt_ios_message'),
|
||||||
|
t('settings.invite_push_prompt_title'),
|
||||||
|
t('settings.invite_push_prompt_later')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const enable = await showConfirm(
|
||||||
|
t('settings.invite_push_prompt_message'),
|
||||||
|
t('settings.invite_push_prompt_title'),
|
||||||
|
t('settings.invite_push_prompt_enable'),
|
||||||
|
t('settings.invite_push_prompt_later')
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!enable) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await enableCollaboratorChangePush()
|
||||||
|
await showAlert(
|
||||||
|
t('settings.invite_push_prompt_success'),
|
||||||
|
t('settings.invite_push_prompt_title')
|
||||||
|
)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Failed to enable push after invite:', err)
|
||||||
|
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleGenerateInvite = async () => {
|
const handleGenerateInvite = async () => {
|
||||||
if (!logbookId) return
|
if (!logbookId) return
|
||||||
setGeneratingInvite(true)
|
setGeneratingInvite(true)
|
||||||
@@ -175,6 +218,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
|
|
||||||
setInviteLink(link)
|
setInviteLink(link)
|
||||||
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
|
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
|
||||||
|
await promptPushAfterInviteCreated()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to generate invite:', err)
|
console.error('Failed to generate invite:', err)
|
||||||
showAlert(err instanceof Error ? err.message : 'Failed to generate invite link.')
|
showAlert(err instanceof Error ? err.message : 'Failed to generate invite link.')
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { clampTankLiters } from '../utils/tankCapacity.js'
|
||||||
|
|
||||||
|
interface TankLiterInputProps {
|
||||||
|
id?: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
maxLiters?: number
|
||||||
|
disabled?: boolean
|
||||||
|
titleTooltip?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInputLiters(value: string): number {
|
||||||
|
const trimmed = value.trim().replace(',', '.')
|
||||||
|
if (!trimmed) return 0
|
||||||
|
const parsed = Number(trimmed)
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TankLiterInput({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
maxLiters,
|
||||||
|
disabled = false,
|
||||||
|
titleTooltip
|
||||||
|
}: TankLiterInputProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const useSlider = maxLiters != null && maxLiters > 0
|
||||||
|
|
||||||
|
const emitValue = useCallback(
|
||||||
|
(liters: number) => {
|
||||||
|
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
|
||||||
|
const str =
|
||||||
|
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
|
||||||
|
onChange(str)
|
||||||
|
},
|
||||||
|
[onChange, maxLiters, useSlider]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNumberBlur = () => {
|
||||||
|
if (!useSlider) return
|
||||||
|
emitValue(parseInputLiters(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
emitValue(Number(e.target.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValue = parseInputLiters(value)
|
||||||
|
const sliderValue = useSlider ? clampTankLiters(numericValue, maxLiters) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="input-group tank-liter-input">
|
||||||
|
<label htmlFor={id} title={titleTooltip}>{label}</label>
|
||||||
|
{useSlider && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="tank-liter-slider"
|
||||||
|
min={0}
|
||||||
|
max={maxLiters}
|
||||||
|
step={1}
|
||||||
|
value={sliderValue}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
disabled={disabled}
|
||||||
|
title={titleTooltip}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={maxLiters}
|
||||||
|
aria-valuenow={sliderValue}
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
<div className="tank-liter-slider-hint" aria-hidden="true">
|
||||||
|
{t('logs.tank_slider_of_max', {
|
||||||
|
current: sliderValue,
|
||||||
|
max: maxLiters
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
className="input-text"
|
||||||
|
value={value}
|
||||||
|
onChange={handleNumberChange}
|
||||||
|
onBlur={handleNumberBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
min={0}
|
||||||
|
max={useSlider ? maxLiters : undefined}
|
||||||
|
step="any"
|
||||||
|
title={titleTooltip}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -443,6 +443,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
</section>
|
</section>
|
||||||
) : profile ? (
|
) : profile ? (
|
||||||
<>
|
<>
|
||||||
|
<div data-tour="profile-preferences">
|
||||||
<section className="form-card">
|
<section className="form-card">
|
||||||
<div className="form-header">
|
<div className="form-header">
|
||||||
<User size={24} className="form-icon" />
|
<User size={24} className="form-icon" />
|
||||||
@@ -484,6 +485,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<UserProfilePreferences userId={profile.userId} />
|
<UserProfilePreferences userId={profile.userId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="member-editor-card glass">
|
<section className="member-editor-card glass">
|
||||||
<div className="profile-section-header">
|
<div className="profile-section-header">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ThemedSelect from './ThemedSelect.tsx'
|
|||||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||||
import { notifyAppearanceChanged } from '../services/appearance.js'
|
import { notifyAppearanceChanged } from '../services/appearance.js'
|
||||||
|
import { saveAppearancePrefsToServer } from '../services/appearancePrefs.js'
|
||||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||||
import {
|
import {
|
||||||
getColorSchemePreference,
|
getColorSchemePreference,
|
||||||
@@ -32,6 +33,9 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
|||||||
setThemePreference(userId, nextTheme)
|
setThemePreference(userId, nextTheme)
|
||||||
setColorSchemePreference(userId, nextColorScheme)
|
setColorSchemePreference(userId, nextColorScheme)
|
||||||
notifyAppearanceChanged()
|
notifyAppearanceChanged()
|
||||||
|
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
|
||||||
|
console.warn('Failed to save appearance prefs to server:', err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleThemeChange = (nextTheme: string) => {
|
const handleThemeChange = (nextTheme: string) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { encryptJson, decryptJson } from '../services/crypto.js'
|
|||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
|
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
|
||||||
|
import { parseOptionalTankLiters, tankCapacityInputFromStored } from '../utils/tankCapacity.js'
|
||||||
|
|
||||||
interface VesselFormProps {
|
interface VesselFormProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -47,6 +48,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
const [mmsi, setMmsi] = useState('')
|
const [mmsi, setMmsi] = useState('')
|
||||||
const [sails, setSails] = useState<string[]>([])
|
const [sails, setSails] = useState<string[]>([])
|
||||||
const [newSailName, setNewSailName] = useState('')
|
const [newSailName, setNewSailName] = useState('')
|
||||||
|
const [freshwaterCapacityL, setFreshwaterCapacityL] = useState('')
|
||||||
|
const [fuelCapacityL, setFuelCapacityL] = useState('')
|
||||||
|
const [greywaterCapacityL, setGreywaterCapacityL] = useState('')
|
||||||
|
|
||||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const [photo, setPhoto] = useState<string | null>(null)
|
const [photo, setPhoto] = useState<string | null>(null)
|
||||||
@@ -78,6 +82,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
setMmsi(preloadedData.mmsi || '')
|
setMmsi(preloadedData.mmsi || '')
|
||||||
setSails(preloadedData.sails || [])
|
setSails(preloadedData.sails || [])
|
||||||
setPhoto(preloadedData.photo || null)
|
setPhoto(preloadedData.photo || null)
|
||||||
|
setFreshwaterCapacityL(tankCapacityInputFromStored(preloadedData.freshwaterCapacityL))
|
||||||
|
setFuelCapacityL(tankCapacityInputFromStored(preloadedData.fuelCapacityL))
|
||||||
|
setGreywaterCapacityL(tankCapacityInputFromStored(preloadedData.greywaterCapacityL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +110,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
setMmsi(decrypted.mmsi || '')
|
setMmsi(decrypted.mmsi || '')
|
||||||
setSails(decrypted.sails || [])
|
setSails(decrypted.sails || [])
|
||||||
setPhoto(decrypted.photo || null)
|
setPhoto(decrypted.photo || null)
|
||||||
|
setFreshwaterCapacityL(tankCapacityInputFromStored(decrypted.freshwaterCapacityL))
|
||||||
|
setFuelCapacityL(tankCapacityInputFromStored(decrypted.fuelCapacityL))
|
||||||
|
setGreywaterCapacityL(tankCapacityInputFromStored(decrypted.greywaterCapacityL))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -201,12 +211,19 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
let parsedLengthM: number | undefined
|
let parsedLengthM: number | undefined
|
||||||
let parsedDraftM: number | undefined
|
let parsedDraftM: number | undefined
|
||||||
let parsedAirDraftM: number | undefined
|
let parsedAirDraftM: number | undefined
|
||||||
|
let parsedFreshwaterCapacityL: number | undefined
|
||||||
|
let parsedFuelCapacityL: number | undefined
|
||||||
|
let parsedGreywaterCapacityL: number | undefined
|
||||||
try {
|
try {
|
||||||
parsedLengthM = parseOptionalMetricMeters(lengthM)
|
parsedLengthM = parseOptionalMetricMeters(lengthM)
|
||||||
parsedDraftM = parseOptionalMetricMeters(draftM)
|
parsedDraftM = parseOptionalMetricMeters(draftM)
|
||||||
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
|
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
|
||||||
} catch {
|
parsedFreshwaterCapacityL = parseOptionalTankLiters(freshwaterCapacityL)
|
||||||
setError(t('vessel.invalid_metric'))
|
parsedFuelCapacityL = parseOptionalTankLiters(fuelCapacityL)
|
||||||
|
parsedGreywaterCapacityL = parseOptionalTankLiters(greywaterCapacityL)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : ''
|
||||||
|
setError(msg === 'invalid_tank_liters' ? t('vessel.invalid_tank_liters') : t('vessel.invalid_metric'))
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -217,6 +234,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
lengthM: parsedLengthM,
|
lengthM: parsedLengthM,
|
||||||
draftM: parsedDraftM,
|
draftM: parsedDraftM,
|
||||||
airDraftM: parsedAirDraftM,
|
airDraftM: parsedAirDraftM,
|
||||||
|
freshwaterCapacityL: parsedFreshwaterCapacityL,
|
||||||
|
fuelCapacityL: parsedFuelCapacityL,
|
||||||
|
greywaterCapacityL: parsedGreywaterCapacityL,
|
||||||
homePort: homePort.trim(),
|
homePort: homePort.trim(),
|
||||||
charterCompany: charterCompany.trim(),
|
charterCompany: charterCompany.trim(),
|
||||||
owner: owner.trim(),
|
owner: owner.trim(),
|
||||||
@@ -480,6 +500,49 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="vessel-tanks-section">
|
||||||
|
<h3>{t('vessel.tanks_section')}</h3>
|
||||||
|
<p className="vessel-tanks-help">{t('vessel.tanks_help')}</p>
|
||||||
|
<div className="vessel-tanks-grid">
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.freshwater_capacity_l')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="input-text"
|
||||||
|
value={freshwaterCapacityL}
|
||||||
|
onChange={(e) => setFreshwaterCapacityL(e.target.value)}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.fuel_capacity_l')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="input-text"
|
||||||
|
value={fuelCapacityL}
|
||||||
|
onChange={(e) => setFuelCapacityL(e.target.value)}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.greywater_capacity_l')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="input-text"
|
||||||
|
value={greywaterCapacityL}
|
||||||
|
onChange={(e) => setGreywaterCapacityL(e.target.value)}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="sails-section">
|
<div className="sails-section">
|
||||||
<h3>{t('vessel.sails_list')}</h3>
|
<h3>{t('vessel.sails_list')}</h3>
|
||||||
<p className="help-text">{t('vessel.sails_help')}</p>
|
<p className="help-text">{t('vessel.sails_help')}</p>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
DEMO_EXCLUDED_STEPS,
|
||||||
|
DEMO_STEP_ORDER,
|
||||||
|
FULL_STEP_ORDER,
|
||||||
|
getTourScrollRetryDelays,
|
||||||
|
getTourTargetRetryDelay,
|
||||||
|
tourStepOpensEntry
|
||||||
|
} from './AppTourContext.tsx'
|
||||||
|
|
||||||
|
describe('AppTourContext step order', () => {
|
||||||
|
it('includes profile steps before finish in full tour', () => {
|
||||||
|
const profileIndex = FULL_STEP_ORDER.indexOf('nav_profile')
|
||||||
|
const prefsIndex = FULL_STEP_ORDER.indexOf('profile_preferences')
|
||||||
|
const finishIndex = FULL_STEP_ORDER.indexOf('finish')
|
||||||
|
|
||||||
|
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
|
||||||
|
expect(prefsIndex).toBe(profileIndex + 1)
|
||||||
|
expect(finishIndex).toBe(prefsIndex + 1)
|
||||||
|
expect(FULL_STEP_ORDER).toHaveLength(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes profile, stats and feedback from demo tour', () => {
|
||||||
|
for (const step of DEMO_EXCLUDED_STEPS) {
|
||||||
|
expect(DEMO_STEP_ORDER).not.toContain(step)
|
||||||
|
}
|
||||||
|
expect(DEMO_STEP_ORDER).toContain('finish')
|
||||||
|
expect(DEMO_STEP_ORDER).toHaveLength(FULL_STEP_ORDER.length - DEMO_EXCLUDED_STEPS.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only opens entry editor on entry_track step', () => {
|
||||||
|
expect(tourStepOpensEntry('entry_open')).toBe(false)
|
||||||
|
expect(tourStepOpensEntry('entry_list')).toBe(false)
|
||||||
|
expect(tourStepOpensEntry('entry_track')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries scroll for entry_track while editor mounts', () => {
|
||||||
|
expect(getTourTargetRetryDelay('entry_track')).toBeGreaterThanOrEqual(400)
|
||||||
|
expect(getTourScrollRetryDelays('entry_track').length).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -29,12 +29,17 @@ export type TourStepId =
|
|||||||
| 'nav_crew'
|
| 'nav_crew'
|
||||||
| 'nav_stats'
|
| 'nav_stats'
|
||||||
| 'nav_feedback'
|
| 'nav_feedback'
|
||||||
|
| 'nav_profile'
|
||||||
|
| 'profile_preferences'
|
||||||
| 'finish'
|
| 'finish'
|
||||||
|
|
||||||
interface TourNavigation {
|
interface TourNavigation {
|
||||||
setActiveTab: (tab: AppTab) => void
|
setActiveTab: (tab: AppTab) => void
|
||||||
setSelectedEntryId: (entryId: string | null) => void
|
setSelectedEntryId: (entryId: string | null) => void
|
||||||
setFeedbackOpen: (open: boolean) => void
|
setFeedbackOpen: (open: boolean) => void
|
||||||
|
setLogbookActive: (active: boolean) => void
|
||||||
|
setProfileOpen: (open: boolean) => void
|
||||||
|
ensureLogbookForTour?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DemoTourContext {
|
interface DemoTourContext {
|
||||||
@@ -47,6 +52,7 @@ interface AppTourContextValue {
|
|||||||
currentStepId: TourStepId | null
|
currentStepId: TourStepId | null
|
||||||
currentStepIndex: number
|
currentStepIndex: number
|
||||||
totalSteps: number
|
totalSteps: number
|
||||||
|
layoutTick: number
|
||||||
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
|
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
|
||||||
stopTour: () => void
|
stopTour: () => void
|
||||||
restartTour: () => void
|
restartTour: () => void
|
||||||
@@ -58,7 +64,7 @@ interface AppTourContextValue {
|
|||||||
requestStartAfterLogin: () => void
|
requestStartAfterLogin: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const FULL_STEP_ORDER: TourStepId[] = [
|
export const FULL_STEP_ORDER: TourStepId[] = [
|
||||||
'welcome',
|
'welcome',
|
||||||
'nav_logs',
|
'nav_logs',
|
||||||
'entry_list',
|
'entry_list',
|
||||||
@@ -68,12 +74,33 @@ const FULL_STEP_ORDER: TourStepId[] = [
|
|||||||
'nav_crew',
|
'nav_crew',
|
||||||
'nav_stats',
|
'nav_stats',
|
||||||
'nav_feedback',
|
'nav_feedback',
|
||||||
|
'nav_profile',
|
||||||
|
'profile_preferences',
|
||||||
'finish'
|
'finish'
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Public demo has no stats/feedback UI — skip those steps. */
|
/** Public demo has no stats/feedback/profile UI — skip those steps. */
|
||||||
const DEMO_EXCLUDED_STEPS: TourStepId[] = ['nav_stats', 'nav_feedback']
|
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
|
||||||
const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter((id) => !DEMO_EXCLUDED_STEPS.includes(id))
|
'nav_stats',
|
||||||
|
'nav_feedback',
|
||||||
|
'nav_profile',
|
||||||
|
'profile_preferences'
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter(
|
||||||
|
(id) => !DEMO_EXCLUDED_STEPS.includes(id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
|
||||||
|
'nav_logs',
|
||||||
|
'entry_list',
|
||||||
|
'entry_open',
|
||||||
|
'entry_track',
|
||||||
|
'nav_vessel',
|
||||||
|
'nav_crew',
|
||||||
|
'nav_stats',
|
||||||
|
'nav_feedback'
|
||||||
|
])
|
||||||
|
|
||||||
function getStepOrder(demoMode: boolean): TourStepId[] {
|
function getStepOrder(demoMode: boolean): TourStepId[] {
|
||||||
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
|
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
|
||||||
@@ -87,7 +114,28 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
|||||||
nav_vessel: '[data-tour="nav-vessel"]',
|
nav_vessel: '[data-tour="nav-vessel"]',
|
||||||
nav_crew: '[data-tour="nav-crew"]',
|
nav_crew: '[data-tour="nav-crew"]',
|
||||||
nav_stats: '[data-tour="stats-dashboard"]',
|
nav_stats: '[data-tour="stats-dashboard"]',
|
||||||
nav_feedback: '[data-tour="feedback-form"]'
|
nav_feedback: '[data-tour="feedback-form"]',
|
||||||
|
nav_profile: '[data-tour="nav-profile"]',
|
||||||
|
profile_preferences: '[data-tour="profile-preferences"]'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a tour step opens the first log entry editor (not the list card). */
|
||||||
|
export function tourStepOpensEntry(stepId: TourStepId): boolean {
|
||||||
|
return stepId === 'entry_track'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTourTargetDelay(stepId: TourStepId): number {
|
||||||
|
if (stepId === 'entry_track') return 400
|
||||||
|
if (stepId === 'nav_feedback') return 180
|
||||||
|
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extra scroll attempts while async UI (e.g. entry editor) mounts. */
|
||||||
|
export function getTourScrollRetryDelays(stepId: TourStepId): number[] {
|
||||||
|
if (stepId === 'entry_track') return [400, 700, 1100, 1600]
|
||||||
|
const initial = getTourTargetDelay(stepId)
|
||||||
|
return initial > 0 ? [initial] : [0]
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppTourContext = createContext<AppTourContextValue | null>(null)
|
const AppTourContext = createContext<AppTourContextValue | null>(null)
|
||||||
@@ -97,6 +145,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
const [stepIndex, setStepIndex] = useState(0)
|
const [stepIndex, setStepIndex] = useState(0)
|
||||||
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
|
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
|
||||||
const [isDemoTour, setIsDemoTour] = useState(false)
|
const [isDemoTour, setIsDemoTour] = useState(false)
|
||||||
|
const [layoutTick, setLayoutTick] = useState(0)
|
||||||
const navigationRef = useRef<TourNavigation | null>(null)
|
const navigationRef = useRef<TourNavigation | null>(null)
|
||||||
const demoContextRef = useRef<DemoTourContext | null>(null)
|
const demoContextRef = useRef<DemoTourContext | null>(null)
|
||||||
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
|
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
|
||||||
@@ -112,13 +161,24 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
const nav = navigationRef.current
|
const nav = navigationRef.current
|
||||||
if (!nav) return
|
if (!nav) return
|
||||||
|
|
||||||
|
if (LOGBOOK_TOUR_STEPS.has(stepId)) {
|
||||||
|
nav.setProfileOpen(false)
|
||||||
|
nav.setLogbookActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
|
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
|
||||||
nav.setActiveTab('logs')
|
nav.setActiveTab('logs')
|
||||||
}
|
}
|
||||||
if (stepId === 'entry_open' || stepId === 'entry_track') {
|
|
||||||
|
if (stepId === 'entry_list' || stepId === 'entry_open') {
|
||||||
|
nav.setSelectedEntryId(null)
|
||||||
|
} else if (tourStepOpensEntry(stepId)) {
|
||||||
const firstEntryId = resolveFirstEntryId()
|
const firstEntryId = resolveFirstEntryId()
|
||||||
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
|
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
|
||||||
|
} else if (LOGBOOK_TOUR_STEPS.has(stepId)) {
|
||||||
|
nav.setSelectedEntryId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stepId === 'nav_vessel') {
|
if (stepId === 'nav_vessel') {
|
||||||
nav.setSelectedEntryId(null)
|
nav.setSelectedEntryId(null)
|
||||||
nav.setActiveTab('vessel')
|
nav.setActiveTab('vessel')
|
||||||
@@ -137,19 +197,34 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
} else {
|
} else {
|
||||||
nav.setFeedbackOpen(false)
|
nav.setFeedbackOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stepId === 'nav_profile') {
|
||||||
|
nav.setProfileOpen(false)
|
||||||
|
nav.setLogbookActive(false)
|
||||||
|
}
|
||||||
|
if (stepId === 'profile_preferences') {
|
||||||
|
nav.setLogbookActive(false)
|
||||||
|
nav.setProfileOpen(true)
|
||||||
|
}
|
||||||
}, [resolveFirstEntryId])
|
}, [resolveFirstEntryId])
|
||||||
|
|
||||||
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
|
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
|
||||||
if (!stepId) return
|
if (!stepId) return
|
||||||
const selector = TARGET_BY_STEP[stepId]
|
const selector = TARGET_BY_STEP[stepId]
|
||||||
if (!selector) return
|
if (!selector) return
|
||||||
const delayMs = stepId === 'nav_feedback' ? 180 : 0
|
|
||||||
window.setTimeout(() => {
|
for (const delayMs of getTourScrollRetryDelays(stepId)) {
|
||||||
window.requestAnimationFrame(() => {
|
window.setTimeout(() => {
|
||||||
const el = document.querySelector(selector)
|
window.requestAnimationFrame(() => {
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
const el = document.querySelector(selector)
|
||||||
})
|
el?.scrollIntoView({
|
||||||
}, delayMs)
|
behavior: stepId === 'entry_track' ? 'instant' : 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, delayMs)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
|
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
|
||||||
@@ -173,6 +248,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
|
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
|
||||||
const nav = navigationRef.current
|
const nav = navigationRef.current
|
||||||
if (nav && !tourModeRef.current.demoMode) {
|
if (nav && !tourModeRef.current.demoMode) {
|
||||||
|
nav.setProfileOpen(false)
|
||||||
|
nav.setLogbookActive(true)
|
||||||
nav.setSelectedEntryId(null)
|
nav.setSelectedEntryId(null)
|
||||||
nav.setActiveTab('stats')
|
nav.setActiveTab('stats')
|
||||||
}
|
}
|
||||||
@@ -183,6 +260,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
tourModeRef.current = { demoMode: false }
|
tourModeRef.current = { demoMode: false }
|
||||||
navigationRef.current?.setFeedbackOpen(false)
|
navigationRef.current?.setFeedbackOpen(false)
|
||||||
|
navigationRef.current?.setProfileOpen(false)
|
||||||
setIsDemoTour(false)
|
setIsDemoTour(false)
|
||||||
setIsActive(false)
|
setIsActive(false)
|
||||||
setStepIndex(0)
|
setStepIndex(0)
|
||||||
@@ -213,8 +291,25 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!isActive) return
|
if (!isActive) return
|
||||||
const stepId = getStepOrder(isDemoTour)[stepIndex]
|
const stepId = getStepOrder(isDemoTour)[stepIndex]
|
||||||
if (!stepId) return
|
if (!stepId) return
|
||||||
applyStepSideEffects(stepId)
|
|
||||||
scrollToCurrentTarget(stepId)
|
let cancelled = false
|
||||||
|
const run = async () => {
|
||||||
|
if (LOGBOOK_TOUR_STEPS.has(stepId) && !isDemoTour) {
|
||||||
|
await navigationRef.current?.ensureLogbookForTour?.()
|
||||||
|
}
|
||||||
|
if (cancelled) return
|
||||||
|
applyStepSideEffects(stepId)
|
||||||
|
scrollToCurrentTarget(stepId)
|
||||||
|
setLayoutTick((tick) => tick + 1)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!cancelled) setLayoutTick((tick) => tick + 1)
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
void run()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
||||||
|
|
||||||
const restartTour = useCallback(() => {
|
const restartTour = useCallback(() => {
|
||||||
@@ -257,6 +352,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
currentStepId,
|
currentStepId,
|
||||||
currentStepIndex: stepIndex,
|
currentStepIndex: stepIndex,
|
||||||
totalSteps: stepOrder.length,
|
totalSteps: stepOrder.length,
|
||||||
|
layoutTick,
|
||||||
startTour,
|
startTour,
|
||||||
stopTour,
|
stopTour,
|
||||||
restartTour,
|
restartTour,
|
||||||
@@ -281,6 +377,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
startTour,
|
startTour,
|
||||||
stepIndex,
|
stepIndex,
|
||||||
stepOrder.length,
|
stepOrder.length,
|
||||||
|
layoutTick,
|
||||||
stopTour
|
stopTour
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -321,3 +418,10 @@ export function getTourTargetSelector(stepId: TourStepId | null): string | null
|
|||||||
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
|
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
|
||||||
return stepId === 'welcome' || stepId === 'finish'
|
return stepId === 'welcome' || stepId === 'finish'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTourTargetRetryDelay(stepId: TourStepId | null): number {
|
||||||
|
if (stepId === 'entry_track') return 400
|
||||||
|
if (stepId === 'profile_preferences') return 300
|
||||||
|
if (stepId === 'nav_profile') return 200
|
||||||
|
return 120
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||||
|
import { markReloadAttempt, recentlyAttemptedReload } from '../services/pwaStartup.js'
|
||||||
|
|
||||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
||||||
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
||||||
const UPDATE_SUPPRESS_MS = 30_000
|
const UPDATE_SUPPRESS_MS = 30_000
|
||||||
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
|
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
|
||||||
const UPDATE_RELOAD_FALLBACK_MS = 2000
|
const UPDATE_RELOAD_FALLBACK_MS = 2000
|
||||||
/** Prevent Android PWA cold-start reload loops from onNeedReload. */
|
|
||||||
const PWA_INITIAL_RELOAD_KEY = 'pwa_sw_initial_reload_done'
|
|
||||||
|
|
||||||
function isUpdateSuppressed(): boolean {
|
function isUpdateSuppressed(): boolean {
|
||||||
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
||||||
@@ -43,6 +42,13 @@ function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reloadForServiceWorkerTakeover(): void {
|
||||||
|
if (recentlyAttemptedReload()) return
|
||||||
|
markReloadAttempt()
|
||||||
|
clearUpdateSuppression()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
export function usePwaUpdate() {
|
export function usePwaUpdate() {
|
||||||
const cleanupRef = useRef<(() => void) | null>(null)
|
const cleanupRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
@@ -52,14 +58,7 @@ export function usePwaUpdate() {
|
|||||||
} = useRegisterSW({
|
} = useRegisterSW({
|
||||||
immediate: !import.meta.env.DEV,
|
immediate: !import.meta.env.DEV,
|
||||||
onNeedReload() {
|
onNeedReload() {
|
||||||
// First SW takeover requires one reload; guard against repeated reloads on Android PWA resume.
|
reloadForServiceWorkerTakeover()
|
||||||
if (sessionStorage.getItem(PWA_INITIAL_RELOAD_KEY)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sessionStorage.setItem(PWA_INITIAL_RELOAD_KEY, '1')
|
|
||||||
clearUpdateSuppression()
|
|
||||||
setNeedRefresh(false)
|
|
||||||
window.location.reload()
|
|
||||||
},
|
},
|
||||||
onNeedRefresh() {
|
onNeedRefresh() {
|
||||||
if (isUpdateSuppressed()) return
|
if (isUpdateSuppressed()) return
|
||||||
@@ -96,7 +95,7 @@ export function usePwaUpdate() {
|
|||||||
|
|
||||||
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
|
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
window.location.reload()
|
reloadForServiceWorkerTakeover()
|
||||||
}, UPDATE_RELOAD_FALLBACK_MS)
|
}, UPDATE_RELOAD_FALLBACK_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,13 @@
|
|||||||
"no_sails": "Keine Segel hinterlegt.",
|
"no_sails": "Keine Segel hinterlegt.",
|
||||||
"photo_add": "Foto hinzufügen",
|
"photo_add": "Foto hinzufügen",
|
||||||
"photo_change": "Foto ändern",
|
"photo_change": "Foto ändern",
|
||||||
"photo_delete": "Foto löschen"
|
"photo_delete": "Foto löschen",
|
||||||
|
"tanks_section": "Tanks (Fassungsvermögen)",
|
||||||
|
"tanks_help": "Optional in Liter — ermöglicht Slider im Journal bei bekannten Tankgrößen.",
|
||||||
|
"freshwater_capacity_l": "Trinkwasser (Liter)",
|
||||||
|
"fuel_capacity_l": "Treibstoff (Liter)",
|
||||||
|
"greywater_capacity_l": "Grauwasser (Liter)",
|
||||||
|
"invalid_tank_liters": "Ungültiger Zahlenwert — bitte Liter als Zahl eingeben (z. B. 200)."
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "Logbuch-Journal",
|
"title": "Logbuch-Journal",
|
||||||
@@ -138,6 +144,10 @@
|
|||||||
"route": "Reise von/nach",
|
"route": "Reise von/nach",
|
||||||
"freshwater": "Frischwasser (Liter)",
|
"freshwater": "Frischwasser (Liter)",
|
||||||
"fuel": "Treibstoff / Fuel (Liter)",
|
"fuel": "Treibstoff / Fuel (Liter)",
|
||||||
|
"greywater": "Grauwasser (Liter)",
|
||||||
|
"greywater_level": "Füllstand",
|
||||||
|
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||||
|
"tank_capacity_tooltip": "Wenn in den Schiffsdaten die Tank-Fassungsvermögen (Liter) hinterlegt sind, kannst du Füllstände hier per Slider eingeben.",
|
||||||
"morning": "Stand morgens",
|
"morning": "Stand morgens",
|
||||||
"refilled": "Nachgefüllt",
|
"refilled": "Nachgefüllt",
|
||||||
"evening": "Stand abends",
|
"evening": "Stand abends",
|
||||||
@@ -183,7 +193,7 @@
|
|||||||
"delete_entry": "Tag löschen",
|
"delete_entry": "Tag löschen",
|
||||||
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
||||||
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
||||||
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser-, Kraftstoff- und Grauwasser-Startstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L\nGrauwasser: {{greywater}} L",
|
||||||
"carry_over_tanks_yes": "Übernehmen",
|
"carry_over_tanks_yes": "Übernehmen",
|
||||||
"carry_over_tanks_no": "Mit 0 starten",
|
"carry_over_tanks_no": "Mit 0 starten",
|
||||||
"event_title": "Chronologisches Ereignisprotokoll",
|
"event_title": "Chronologisches Ereignisprotokoll",
|
||||||
@@ -472,6 +482,12 @@
|
|||||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||||
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
||||||
"deleting_account": "Konto wird gelöscht…",
|
"deleting_account": "Konto wird gelöscht…",
|
||||||
|
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
|
||||||
|
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
|
||||||
|
"invite_push_prompt_ios_message": "Sobald Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), dann Push im Benutzerprofil aktivieren.",
|
||||||
|
"invite_push_prompt_enable": "Jetzt aktivieren",
|
||||||
|
"invite_push_prompt_later": "Später",
|
||||||
|
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
||||||
"backup_title": "Backup & Wiederherstellung",
|
"backup_title": "Backup & Wiederherstellung",
|
||||||
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
|
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
|
||||||
"backup_export_title": "Backup erstellen",
|
"backup_export_title": "Backup erstellen",
|
||||||
@@ -672,9 +688,17 @@
|
|||||||
"title": "Feedback senden",
|
"title": "Feedback senden",
|
||||||
"body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken – auch nach der Tour jederzeit über das Symbol oben rechts."
|
"body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken – auch nach der Tour jederzeit über das Symbol oben rechts."
|
||||||
},
|
},
|
||||||
|
"nav_profile": {
|
||||||
|
"title": "Dein Benutzerprofil",
|
||||||
|
"body": "Über den Skipper-Button oben erreichst du dein persönliches Profil – unabhängig vom aktuellen Logbuch."
|
||||||
|
},
|
||||||
|
"profile_preferences": {
|
||||||
|
"title": "Konto & Darstellung",
|
||||||
|
"body": "Hier verwaltest du deine Konto-Identität, Theme und Hell/Dunkel-Modus. Die App-Tour kannst du jederzeit erneut starten. Passkeys und Sicherheitseinstellungen findest du weiter unten im Profil."
|
||||||
|
},
|
||||||
"finish": {
|
"finish": {
|
||||||
"title": "Alles klar!",
|
"title": "Alles klar!",
|
||||||
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
|
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit im Benutzerprofil erneut starten. Gute Fahrt!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -117,7 +117,13 @@
|
|||||||
"no_sails": "No sails defined.",
|
"no_sails": "No sails defined.",
|
||||||
"photo_add": "Add Photo",
|
"photo_add": "Add Photo",
|
||||||
"photo_change": "Change Photo",
|
"photo_change": "Change Photo",
|
||||||
"photo_delete": "Delete Photo"
|
"photo_delete": "Delete Photo",
|
||||||
|
"tanks_section": "Tanks (capacity)",
|
||||||
|
"tanks_help": "Optional, in liters — enables sliders in the journal when tank sizes are known.",
|
||||||
|
"freshwater_capacity_l": "Freshwater (liters)",
|
||||||
|
"fuel_capacity_l": "Fuel (liters)",
|
||||||
|
"greywater_capacity_l": "Greywater (liters)",
|
||||||
|
"invalid_tank_liters": "Invalid number — please enter capacity in liters (e.g. 200)."
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "Logbook Journal",
|
"title": "Logbook Journal",
|
||||||
@@ -138,6 +144,10 @@
|
|||||||
"route": "Route / Journey",
|
"route": "Route / Journey",
|
||||||
"freshwater": "Freshwater (Liters)",
|
"freshwater": "Freshwater (Liters)",
|
||||||
"fuel": "Fuel (Liters)",
|
"fuel": "Fuel (Liters)",
|
||||||
|
"greywater": "Greywater (Liters)",
|
||||||
|
"greywater_level": "Fill level",
|
||||||
|
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||||
|
"tank_capacity_tooltip": "If tank capacities (liters) are set in vessel master data, you can enter fill levels here using sliders.",
|
||||||
"morning": "Morning Level",
|
"morning": "Morning Level",
|
||||||
"refilled": "Refilled",
|
"refilled": "Refilled",
|
||||||
"evening": "Evening Level",
|
"evening": "Evening Level",
|
||||||
@@ -183,7 +193,7 @@
|
|||||||
"delete_entry": "Delete Day",
|
"delete_entry": "Delete Day",
|
||||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||||
"carry_over_tanks_title": "Carry over from previous day?",
|
"carry_over_tanks_title": "Carry over from previous day?",
|
||||||
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
|
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L\nGreywater: {{greywater}} L",
|
||||||
"carry_over_tanks_yes": "Carry over",
|
"carry_over_tanks_yes": "Carry over",
|
||||||
"carry_over_tanks_no": "Start at 0",
|
"carry_over_tanks_no": "Start at 0",
|
||||||
"event_title": "Chronological Event Logbook",
|
"event_title": "Chronological Event Logbook",
|
||||||
@@ -472,6 +482,12 @@
|
|||||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||||
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
||||||
"deleting_account": "Deleting account…",
|
"deleting_account": "Deleting account…",
|
||||||
|
"invite_push_prompt_title": "Enable push notifications?",
|
||||||
|
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
|
||||||
|
"invite_push_prompt_ios_message": "When crew members sync changes, you can get push notifications. On iPhone/iPad: add the app to your Home Screen (iOS 16.4+), then enable push in your user profile.",
|
||||||
|
"invite_push_prompt_enable": "Enable now",
|
||||||
|
"invite_push_prompt_later": "Later",
|
||||||
|
"invite_push_prompt_success": "Push notifications are active on this device.",
|
||||||
"backup_title": "Backup & restore",
|
"backup_title": "Backup & restore",
|
||||||
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
|
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
|
||||||
"backup_export_title": "Create backup",
|
"backup_export_title": "Create backup",
|
||||||
@@ -672,9 +688,17 @@
|
|||||||
"title": "Send feedback",
|
"title": "Send feedback",
|
||||||
"body": "Use this form to report bugs, ideas, or general feedback to the project team — you can also open it anytime later via the icon in the top right."
|
"body": "Use this form to report bugs, ideas, or general feedback to the project team — you can also open it anytime later via the icon in the top right."
|
||||||
},
|
},
|
||||||
|
"nav_profile": {
|
||||||
|
"title": "Your user profile",
|
||||||
|
"body": "Tap the skipper button at the top to open your personal profile — independent of the current logbook."
|
||||||
|
},
|
||||||
|
"profile_preferences": {
|
||||||
|
"title": "Account & appearance",
|
||||||
|
"body": "Manage your account identity, theme, and light/dark mode here. You can restart the app tour anytime. Passkeys and security settings are further down on the profile page."
|
||||||
|
},
|
||||||
"finish": {
|
"finish": {
|
||||||
"title": "You're all set!",
|
"title": "You're all set!",
|
||||||
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!"
|
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime from your user profile. Fair winds!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -100,12 +100,10 @@ code,
|
|||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-h);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 135%;
|
line-height: 135%;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import './index.css'
|
|||||||
import './i18n'
|
import './i18n'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { applyAppearanceToDocument } from './services/appearance.ts'
|
import { applyAppearanceToDocument } from './services/appearance.ts'
|
||||||
|
import {
|
||||||
|
installStaleAssetRecovery,
|
||||||
|
markReloadAttempt,
|
||||||
|
reconcileServiceWorkerOnStartup
|
||||||
|
} from './services/pwaStartup.ts'
|
||||||
|
|
||||||
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||||
async function clearDevServiceWorkerCaches(): Promise<void> {
|
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||||
@@ -35,8 +40,16 @@ function renderBootstrapError(message: string): void {
|
|||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
applyAppearanceToDocument()
|
applyAppearanceToDocument()
|
||||||
|
installStaleAssetRecovery()
|
||||||
await clearDevServiceWorkerCaches()
|
await clearDevServiceWorkerCaches()
|
||||||
|
|
||||||
|
const shouldReloadForWaitingSw = await reconcileServiceWorkerOnStartup()
|
||||||
|
if (shouldReloadForWaitingSw) {
|
||||||
|
markReloadAttempt()
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const rootEl = document.getElementById('root')
|
const rootEl = document.getElementById('root')
|
||||||
if (!rootEl) {
|
if (!rootEl) {
|
||||||
throw new Error('Missing #root element')
|
throw new Error('Missing #root element')
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
fetchAppearancePrefs,
|
||||||
|
saveAppearancePrefsToServer,
|
||||||
|
syncAppearancePrefs
|
||||||
|
} from './appearancePrefs.js'
|
||||||
|
import { setThemePreference } from './userPreferences.js'
|
||||||
|
|
||||||
|
const USER_ID = 'appearance-sync-user'
|
||||||
|
|
||||||
|
vi.mock('./api.js', () => ({
|
||||||
|
apiJson: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { apiJson } from './api.js'
|
||||||
|
|
||||||
|
const mockedApiJson = vi.mocked(apiJson)
|
||||||
|
|
||||||
|
describe('appearancePrefs', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchAppearancePrefs returns defaults when not authenticated', async () => {
|
||||||
|
await expect(fetchAppearancePrefs()).resolves.toEqual({
|
||||||
|
theme: 'auto',
|
||||||
|
colorScheme: 'auto',
|
||||||
|
persisted: false
|
||||||
|
})
|
||||||
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncAppearancePrefs applies server prefs after cache wipe', async () => {
|
||||||
|
localStorage.setItem('active_userid', USER_ID)
|
||||||
|
mockedApiJson.mockResolvedValueOnce({
|
||||||
|
theme: 'ocean',
|
||||||
|
colorScheme: 'dark',
|
||||||
|
persisted: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const changed = vi.fn()
|
||||||
|
window.addEventListener('appearance-changed', changed)
|
||||||
|
|
||||||
|
await syncAppearancePrefs(USER_ID)
|
||||||
|
|
||||||
|
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
||||||
|
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
|
||||||
|
expect(changed).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncAppearancePrefs uploads local prefs when server has none', async () => {
|
||||||
|
localStorage.setItem('active_userid', USER_ID)
|
||||||
|
setThemePreference(USER_ID, 'material')
|
||||||
|
mockedApiJson
|
||||||
|
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
|
||||||
|
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
|
||||||
|
|
||||||
|
await syncAppearancePrefs(USER_ID)
|
||||||
|
|
||||||
|
expect(mockedApiJson).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
|
||||||
|
await saveAppearancePrefsToServer('ocean', 'light')
|
||||||
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { apiJson } from './api.js'
|
||||||
|
import { notifyAppearanceChanged } from './appearance.js'
|
||||||
|
import {
|
||||||
|
getActiveUserId,
|
||||||
|
getColorSchemePreference,
|
||||||
|
getThemePreference,
|
||||||
|
setColorSchemePreference,
|
||||||
|
setThemePreference
|
||||||
|
} from './userPreferences.js'
|
||||||
|
|
||||||
|
const API_BASE = '/api/auth/appearance-prefs'
|
||||||
|
|
||||||
|
export interface AppearancePrefs {
|
||||||
|
theme: string
|
||||||
|
colorScheme: string
|
||||||
|
persisted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLocalAppearancePrefs(userId: string): boolean {
|
||||||
|
return (
|
||||||
|
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
|
||||||
|
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAppearancePrefs(): Promise<AppearancePrefs> {
|
||||||
|
if (!getActiveUserId()) {
|
||||||
|
return { theme: 'auto', colorScheme: 'auto', persisted: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiJson<AppearancePrefs>(API_BASE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAppearancePrefsToServer(theme: string, colorScheme: string): Promise<void> {
|
||||||
|
if (!getActiveUserId()) return
|
||||||
|
|
||||||
|
await apiJson<AppearancePrefs>(API_BASE, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ theme, colorScheme })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge server-stored appearance with local cache (server wins after cache wipe). */
|
||||||
|
export async function syncAppearancePrefs(userId?: string | null): Promise<void> {
|
||||||
|
const id = userId?.trim() || getActiveUserId()
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const server = await fetchAppearancePrefs()
|
||||||
|
|
||||||
|
if (server.persisted) {
|
||||||
|
setThemePreference(id, server.theme)
|
||||||
|
setColorSchemePreference(id, server.colorScheme)
|
||||||
|
} else if (hasLocalAppearancePrefs(id)) {
|
||||||
|
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to sync appearance preferences:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyAppearanceChanged()
|
||||||
|
}
|
||||||
@@ -88,6 +88,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
'Latitude', 'Longitude', 'Remarks',
|
'Latitude', 'Longitude', 'Remarks',
|
||||||
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
||||||
'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)',
|
'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)',
|
||||||
|
'Greywater Level (L)',
|
||||||
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
|
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -123,6 +124,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
const fuelR = entry.fuel?.refilled ?? '';
|
const fuelR = entry.fuel?.refilled ?? '';
|
||||||
const fuelE = entry.fuel?.evening ?? '';
|
const fuelE = entry.fuel?.evening ?? '';
|
||||||
const fuelCons = entry.fuel?.consumption ?? '';
|
const fuelCons = entry.fuel?.consumption ?? '';
|
||||||
|
const greywaterLevel = entry.greywater?.level ?? '';
|
||||||
|
|
||||||
const eventsList = entry.events || [];
|
const eventsList = entry.events || [];
|
||||||
if (eventsList.length === 0) {
|
if (eventsList.length === 0) {
|
||||||
@@ -137,6 +139,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
'', '', '',
|
'', '', '',
|
||||||
fwM, fwR, fwE, fwCons,
|
fwM, fwR, fwE, fwCons,
|
||||||
fuelM, fuelR, fuelE, fuelCons,
|
fuelM, fuelR, fuelE, fuelCons,
|
||||||
|
greywaterLevel,
|
||||||
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
||||||
].map(escapeCsvValue));
|
].map(escapeCsvValue));
|
||||||
} else {
|
} else {
|
||||||
@@ -153,6 +156,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
||||||
fwM, fwR, fwE, fwCons,
|
fwM, fwR, fwE, fwCons,
|
||||||
fuelM, fuelR, fuelE, fuelCons,
|
fuelM, fuelR, fuelE, fuelCons,
|
||||||
|
greywaterLevel,
|
||||||
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
||||||
].map(escapeCsvValue));
|
].map(escapeCsvValue));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
clearDemoLogbookRefs,
|
||||||
|
getDemoFirstEntryStorageKey,
|
||||||
|
getDemoLogbookStorageKey
|
||||||
|
} from './demoLogbook.js'
|
||||||
|
|
||||||
|
describe('clearDemoLogbookRefs', () => {
|
||||||
|
const userId = 'user-1'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
localStorage.setItem('active_userid', userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes demo logbook and first-entry keys for the user', () => {
|
||||||
|
const logbookId = 'lb-demo'
|
||||||
|
localStorage.setItem(getDemoLogbookStorageKey(userId), logbookId)
|
||||||
|
localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1')
|
||||||
|
|
||||||
|
clearDemoLogbookRefs(userId, logbookId)
|
||||||
|
|
||||||
|
expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBeNull()
|
||||||
|
expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not clear refs when logbookId does not match stored demo id', () => {
|
||||||
|
localStorage.setItem(getDemoLogbookStorageKey(userId), 'other-logbook')
|
||||||
|
localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1')
|
||||||
|
|
||||||
|
clearDemoLogbookRefs(userId, 'deleted-logbook')
|
||||||
|
|
||||||
|
expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBe('other-logbook')
|
||||||
|
expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBe('entry-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -108,6 +108,7 @@ export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null>
|
|||||||
const title = i18n.t('demo.logbook_title')
|
const title = i18n.t('demo.logbook_title')
|
||||||
return { logbookId: existingId, title, firstEntryId }
|
return { logbookId: existingId, title, firstEntryId }
|
||||||
}
|
}
|
||||||
|
clearDemoLogbookRefs(userId, existingId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldSeed) return null
|
if (!shouldSeed) return null
|
||||||
@@ -152,3 +153,66 @@ export function getStoredDemoFirstEntryId(): string | null {
|
|||||||
if (!userId) return null
|
if (!userId) return null
|
||||||
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove persisted demo logbook pointers when the logbook no longer exists. */
|
||||||
|
export function clearDemoLogbookRefs(userId: string, logbookId?: string): void {
|
||||||
|
const storedId = localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||||
|
if (logbookId && storedId && storedId !== logbookId) return
|
||||||
|
localStorage.removeItem(getDemoLogbookStorageKey(userId))
|
||||||
|
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function entryExistsInLogbook(logbookId: string, entryId: string): Promise<boolean> {
|
||||||
|
const entry = await db.entries.get(entryId)
|
||||||
|
return entry?.logbookId === logbookId
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourLogbookContext {
|
||||||
|
logbookId: string
|
||||||
|
title: string
|
||||||
|
firstEntryId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick a logbook + first entry for the onboarding tour (handles deleted demo data). */
|
||||||
|
export async function resolveTourLogbookContext(
|
||||||
|
preferLogbookId?: string | null
|
||||||
|
): Promise<TourLogbookContext | null> {
|
||||||
|
const userId = localStorage.getItem('active_userid')
|
||||||
|
if (!userId || !getActiveMasterKey()) return null
|
||||||
|
|
||||||
|
const demoId = localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||||
|
if (demoId && !(await db.logbooks.get(demoId))) {
|
||||||
|
clearDemoLogbookRefs(userId, demoId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fetchLogbooks } = await import('./logbook.js')
|
||||||
|
const books = await fetchLogbooks()
|
||||||
|
if (books.length === 0) return null
|
||||||
|
|
||||||
|
const activeId = localStorage.getItem('active_logbook_id')
|
||||||
|
const pick =
|
||||||
|
(preferLogbookId ? books.find((b) => b.id === preferLogbookId) : undefined) ??
|
||||||
|
(activeId ? books.find((b) => b.id === activeId) : undefined) ??
|
||||||
|
(demoId ? books.find((b) => b.id === demoId) : undefined) ??
|
||||||
|
books[0]
|
||||||
|
|
||||||
|
const firstEntryId = await resolveTourFirstEntryId(pick.id, userId)
|
||||||
|
return { logbookId: pick.id, title: pick.title, firstEntryId }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTourFirstEntryId(logbookId: string, userId: string): Promise<string | null> {
|
||||||
|
const stored = localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
||||||
|
if (stored && (await entryExistsInLogbook(logbookId, stored))) {
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||||
|
if (localEntries.length === 0) return null
|
||||||
|
|
||||||
|
localEntries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||||
|
return localEntries[0]?.payloadId ?? null
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
greywaterLevel?: number
|
||||||
motorHours?: number
|
motorHours?: number
|
||||||
events: Array<Record<string, string>>
|
events: Array<Record<string, string>>
|
||||||
}
|
}
|
||||||
@@ -69,6 +70,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
|||||||
filename: 'kiel-laboe.gpx',
|
filename: 'kiel-laboe.gpx',
|
||||||
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
|
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
|
||||||
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
|
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
|
||||||
|
greywaterLevel: 25,
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
time: '10:15',
|
time: '10:15',
|
||||||
@@ -101,6 +103,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 },
|
||||||
|
greywaterLevel: 38,
|
||||||
motorHours: 1.5,
|
motorHours: 1.5,
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
@@ -134,6 +137,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
|||||||
filename: 'damp-schleimuende.gpx',
|
filename: 'damp-schleimuende.gpx',
|
||||||
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
|
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
|
||||||
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
|
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
|
||||||
|
greywaterLevel: 52,
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
time: '08:30',
|
time: '08:30',
|
||||||
@@ -176,7 +180,10 @@ export function buildDemoYachtData(): Record<string, unknown> {
|
|||||||
atis: '',
|
atis: '',
|
||||||
mmsi: '',
|
mmsi: '',
|
||||||
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
|
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
|
||||||
photo: null
|
photo: null,
|
||||||
|
freshwaterCapacityL: 200,
|
||||||
|
fuelCapacityL: 100,
|
||||||
|
greywaterCapacityL: 80
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +251,10 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
|||||||
events: day.events
|
events: day.events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
|
||||||
|
entryPayload.greywater = { level: day.greywaterLevel }
|
||||||
|
}
|
||||||
|
|
||||||
if (stats) {
|
if (stats) {
|
||||||
entryPayload.trackDistanceNm = stats.distanceNm
|
entryPayload.trackDistanceNm = stats.distanceNm
|
||||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||||
@@ -303,6 +314,10 @@ export function buildDemoEntryPayloads(): Array<{
|
|||||||
events: day.events
|
events: day.events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
|
||||||
|
entryPayload.greywater = { level: day.greywaterLevel }
|
||||||
|
}
|
||||||
|
|
||||||
if (stats) {
|
if (stats) {
|
||||||
entryPayload.trackDistanceNm = stats.distanceNm
|
entryPayload.trackDistanceNm = stats.distanceNm
|
||||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto
|
|||||||
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
import { apiFetch } from './api.js'
|
import { apiFetch } from './api.js'
|
||||||
|
import { clearDemoLogbookRefs, getDemoLogbookStorageKey } from './demoLogbook.js'
|
||||||
|
|
||||||
const API_BASE = '/api/logbooks'
|
const API_BASE = '/api/logbooks'
|
||||||
|
|
||||||
@@ -320,6 +321,9 @@ export async function deleteLogbook(id: string): Promise<void> {
|
|||||||
|
|
||||||
// Perform local cascading cleanup
|
// Perform local cascading cleanup
|
||||||
await deleteLocalLogbookCache(id)
|
await deleteLocalLogbookCache(id)
|
||||||
|
if (userId && id === localStorage.getItem(getDemoLogbookStorageKey(userId))) {
|
||||||
|
clearDemoLogbookRefs(userId, id)
|
||||||
|
}
|
||||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,13 +197,15 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
doc.text('VERBRAUCHSWERTE / CONSUMPTON STATS', 10, footerY + 3);
|
doc.text('VERBRAUCHSWERTE / CONSUMPTON STATS', 10, footerY + 3);
|
||||||
|
|
||||||
let fwY = footerY + 5;
|
let fwY = footerY + 5;
|
||||||
doc.rect(10, fwY, 110, rowHeight * 3, 'S');
|
const tankRows = 4;
|
||||||
|
doc.rect(10, fwY, 110, rowHeight * tankRows, 'S');
|
||||||
doc.line(10, fwY + rowHeight, 120, fwY + rowHeight);
|
doc.line(10, fwY + rowHeight, 120, fwY + rowHeight);
|
||||||
doc.line(10, fwY + rowHeight * 2, 120, fwY + rowHeight * 2);
|
doc.line(10, fwY + rowHeight * 2, 120, fwY + rowHeight * 2);
|
||||||
doc.line(40, fwY, 40, fwY + rowHeight * 3);
|
doc.line(10, fwY + rowHeight * 3, 120, fwY + rowHeight * 3);
|
||||||
doc.line(60, fwY, 60, fwY + rowHeight * 3);
|
doc.line(40, fwY, 40, fwY + rowHeight * tankRows);
|
||||||
doc.line(80, fwY, 80, fwY + rowHeight * 3);
|
doc.line(60, fwY, 60, fwY + rowHeight * tankRows);
|
||||||
doc.line(100, fwY, 100, fwY + rowHeight * 3);
|
doc.line(80, fwY, 80, fwY + rowHeight * tankRows);
|
||||||
|
doc.line(100, fwY, 100, fwY + rowHeight * tankRows);
|
||||||
|
|
||||||
doc.setFont('Helvetica', 'bold');
|
doc.setFont('Helvetica', 'bold');
|
||||||
doc.setFontSize(7.5);
|
doc.setFontSize(7.5);
|
||||||
@@ -226,6 +228,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
doc.text(String(entry.fuel?.evening ?? '0'), 81, fwY + rowHeight * 2 + 4.2);
|
doc.text(String(entry.fuel?.evening ?? '0'), 81, fwY + rowHeight * 2 + 4.2);
|
||||||
doc.text(String(entry.fuel?.consumption ?? '0'), 101, fwY + rowHeight * 2 + 4.2);
|
doc.text(String(entry.fuel?.consumption ?? '0'), 101, fwY + rowHeight * 2 + 4.2);
|
||||||
|
|
||||||
|
doc.text('Grauwasser', 11, fwY + rowHeight * 3 + 4.2);
|
||||||
|
doc.text('—', 41, fwY + rowHeight * 3 + 4.2);
|
||||||
|
doc.text('—', 61, fwY + rowHeight * 3 + 4.2);
|
||||||
|
doc.text(String(entry.greywater?.level ?? '0'), 81, fwY + rowHeight * 3 + 4.2);
|
||||||
|
doc.text('—', 101, fwY + rowHeight * 3 + 4.2);
|
||||||
|
|
||||||
// Signatures Box
|
// Signatures Box
|
||||||
let sigX = 130;
|
let sigX = 130;
|
||||||
let sigY = footerY + 5;
|
let sigY = footerY + 5;
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ async function fetchVapidPublicKey(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when crew-change push is enabled and notification permission is granted. */
|
||||||
|
export async function isCollaboratorPushActive(): Promise<boolean> {
|
||||||
|
if (!isPushSupported()) return false
|
||||||
|
if (getNotificationPermission() !== 'granted') return false
|
||||||
|
try {
|
||||||
|
const prefs = await fetchPushPrefs()
|
||||||
|
return prefs.collaboratorChangesEnabled
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
|
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
|
||||||
if (!localStorage.getItem('active_userid')) {
|
if (!localStorage.getItem('active_userid')) {
|
||||||
return { collaboratorChangesEnabled: false }
|
return { collaboratorChangesEnabled: false }
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
markReloadAttempt,
|
||||||
|
recentlyAttemptedReload,
|
||||||
|
reconcileServiceWorkerOnStartup
|
||||||
|
} from './pwaStartup.js'
|
||||||
|
|
||||||
|
describe('pwaStartup reload guards', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks repeated reload attempts within the debounce window', () => {
|
||||||
|
expect(recentlyAttemptedReload(10_000)).toBe(false)
|
||||||
|
markReloadAttempt(10_000)
|
||||||
|
expect(recentlyAttemptedReload(12_000)).toBe(true)
|
||||||
|
expect(recentlyAttemptedReload(15_000)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reconcileServiceWorkerOnStartup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false in dev mode', async () => {
|
||||||
|
vi.stubEnv('DEV', true)
|
||||||
|
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when no waiting worker exists', async () => {
|
||||||
|
vi.stubEnv('DEV', false)
|
||||||
|
Object.defineProperty(navigator, 'serviceWorker', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
controller: {},
|
||||||
|
getRegistration: vi.fn().mockResolvedValue({ waiting: null }),
|
||||||
|
addEventListener: vi.fn()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts'
|
||||||
|
const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts'
|
||||||
|
const RELOAD_DEBOUNCE_MS = 4_000
|
||||||
|
const COLD_START_UPDATE_DEBOUNCE_MS = 15_000
|
||||||
|
|
||||||
|
export function recentlyAttemptedReload(now = Date.now()): boolean {
|
||||||
|
const last = Number(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0')
|
||||||
|
return now - last < RELOAD_DEBOUNCE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markReloadAttempt(now = Date.now()): void {
|
||||||
|
sessionStorage.setItem(RELOAD_ATTEMPT_KEY, String(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentlyAttemptedColdStartUpdate(now = Date.now()): boolean {
|
||||||
|
const last = Number(sessionStorage.getItem(COLD_START_UPDATE_KEY) || '0')
|
||||||
|
return now - last < COLD_START_UPDATE_DEBOUNCE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function markColdStartUpdateAttempt(now = Date.now()): void {
|
||||||
|
sessionStorage.setItem(COLD_START_UPDATE_KEY, String(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaleModuleLoadError(error: unknown): boolean {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === 'string'
|
||||||
|
? error
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
message.includes('Failed to fetch dynamically imported module') ||
|
||||||
|
message.includes('Importing a module script failed') ||
|
||||||
|
message.includes('error loading dynamically imported module')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After missed deploys, a waiting SW may exist while the page still runs an old bundle.
|
||||||
|
* Apply the waiting worker once on cold start (one controlled reload) instead of hanging.
|
||||||
|
*/
|
||||||
|
export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
|
||||||
|
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentlyAttemptedColdStartUpdate()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration()
|
||||||
|
const waiting = registration?.waiting
|
||||||
|
if (!waiting || !navigator.serviceWorker.controller) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
markColdStartUpdateAttempt()
|
||||||
|
waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeoutId = window.setTimeout(resolve, 4_000)
|
||||||
|
navigator.serviceWorker.addEventListener(
|
||||||
|
'controllerchange',
|
||||||
|
() => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
resolve()
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installStaleAssetRecovery(): void {
|
||||||
|
if (import.meta.env.DEV) return
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
if (!isStaleModuleLoadError(event.reason)) return
|
||||||
|
if (recentlyAttemptedReload()) return
|
||||||
|
|
||||||
|
markReloadAttempt()
|
||||||
|
event.preventDefault()
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
|
import { clientsClaim } from 'workbox-core'
|
||||||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||||
|
|
||||||
declare let self: ServiceWorkerGlobalScope
|
declare let self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
precacheAndRoute(self.__WB_MANIFEST)
|
precacheAndRoute(self.__WB_MANIFEST)
|
||||||
cleanupOutdatedCaches()
|
cleanupOutdatedCaches()
|
||||||
|
clientsClaim()
|
||||||
|
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'SKIP_WAITING') {
|
||||||
|
void self.skipWaiting()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
interface PushPayload {
|
interface PushPayload {
|
||||||
title?: string
|
title?: string
|
||||||
|
|||||||
@@ -403,3 +403,12 @@ html.scheme-light.theme-cupertino {
|
|||||||
html.scheme-light #root {
|
html.scheme-light #root {
|
||||||
border-inline-color: var(--app-border-subtle);
|
border-inline-color: var(--app-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bridge legacy index.css tokens to appearance (avoids system color-scheme drift) */
|
||||||
|
html.scheme-light,
|
||||||
|
html.scheme-dark {
|
||||||
|
--text: var(--app-text);
|
||||||
|
--text-h: var(--app-text-heading);
|
||||||
|
--code-bg: var(--app-icon-btn-bg);
|
||||||
|
--border: var(--app-border-subtle);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildLogEntryPayload,
|
||||||
|
hasUnsavedEventDraft,
|
||||||
|
isLogEventDraftEmpty,
|
||||||
|
normalizeLogEvent,
|
||||||
|
type LogEventPayload
|
||||||
|
} from './logEntryPayload.js'
|
||||||
|
|
||||||
|
const emptyDraft = (): LogEventPayload =>
|
||||||
|
normalizeLogEvent({ time: '12:34' })
|
||||||
|
|
||||||
|
const filledDraft = (): LogEventPayload =>
|
||||||
|
normalizeLogEvent({ time: '12:34', remarks: 'Wind dreht' })
|
||||||
|
|
||||||
|
describe('logEntryPayload event drafts', () => {
|
||||||
|
it('treats time-only draft as empty', () => {
|
||||||
|
expect(isLogEventDraftEmpty(emptyDraft())).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects draft with content', () => {
|
||||||
|
expect(isLogEventDraftEmpty(filledDraft())).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag empty open form as unsaved', () => {
|
||||||
|
expect(hasUnsavedEventDraft(emptyDraft(), null, [])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags new event draft with content as unsaved', () => {
|
||||||
|
expect(hasUnsavedEventDraft(filledDraft(), null, [])).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags edited event when values differ', () => {
|
||||||
|
const events = [emptyDraft()]
|
||||||
|
const edited = filledDraft()
|
||||||
|
expect(hasUnsavedEventDraft(edited, 0, events)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores edit mode when values match', () => {
|
||||||
|
const events = [filledDraft()]
|
||||||
|
expect(hasUnsavedEventDraft(filledDraft(), 0, events)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildLogEntryPayload greywater', () => {
|
||||||
|
const base = {
|
||||||
|
date: '2026-05-31',
|
||||||
|
dayOfTravel: '1',
|
||||||
|
departure: 'Kiel',
|
||||||
|
destination: 'Laboe',
|
||||||
|
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
events: [] as LogEventPayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
it('includes greywater when level > 0', () => {
|
||||||
|
const payload = buildLogEntryPayload({ ...base, greywater: { level: 45 } })
|
||||||
|
expect(payload.greywater).toEqual({ level: 45 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits greywater when level is 0', () => {
|
||||||
|
const payload = buildLogEntryPayload({ ...base, greywater: { level: 0 } })
|
||||||
|
expect(payload.greywater).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -111,6 +111,27 @@ export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean
|
|||||||
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time')
|
||||||
|
|
||||||
|
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */
|
||||||
|
export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
|
||||||
|
return LOG_EVENT_CONTENT_FIELDS.every((key) => !event[key]?.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the event form holds unsaved changes worth merging on page save. */
|
||||||
|
export function hasUnsavedEventDraft(
|
||||||
|
draft: LogEventPayload,
|
||||||
|
editingEventIndex: number | null,
|
||||||
|
events: LogEventPayload[]
|
||||||
|
): boolean {
|
||||||
|
if (!isValidTimeHHMM(draft.time)) return false
|
||||||
|
if (editingEventIndex !== null) {
|
||||||
|
const original = events[editingEventIndex]
|
||||||
|
return original ? !logEventsEqual(draft, original) : false
|
||||||
|
}
|
||||||
|
return !isLogEventDraftEmpty(draft)
|
||||||
|
}
|
||||||
|
|
||||||
/** Chronological order: earliest time first (HH:MM). */
|
/** Chronological order: earliest time first (HH:MM). */
|
||||||
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
|
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
|
||||||
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
||||||
@@ -123,6 +144,7 @@ export interface LogEntryPayloadInput {
|
|||||||
destination: string
|
destination: 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 }
|
||||||
|
greywater?: { level: number }
|
||||||
trackDistanceNm?: number
|
trackDistanceNm?: number
|
||||||
trackSpeedMaxKn?: number
|
trackSpeedMaxKn?: number
|
||||||
trackSpeedAvgKn?: number
|
trackSpeedAvgKn?: number
|
||||||
@@ -148,5 +170,12 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
|
|||||||
payload.motorHours = Number(input.motorHours.toFixed(2))
|
payload.motorHours = Number(input.motorHours.toFixed(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.greywater !== undefined) {
|
||||||
|
const level = Number(input.greywater.level) || 0
|
||||||
|
if (level > 0) {
|
||||||
|
payload.greywater = { level: Number(level.toFixed(1)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
carryOverFromPreviousDay,
|
||||||
|
getClosingGreywaterLevel,
|
||||||
|
hasCarryOverFromPreviousDay
|
||||||
|
} from './logEntryTankLevels.js'
|
||||||
|
|
||||||
|
describe('logEntryTankLevels greywater carry-over', () => {
|
||||||
|
it('returns previous greywater level as starting value', () => {
|
||||||
|
const carryOver = carryOverFromPreviousDay({
|
||||||
|
destination: 'Oslo',
|
||||||
|
freshwater: { morning: 100, refilled: 0, evening: 80, consumption: 20 },
|
||||||
|
fuel: { morning: 200, refilled: 0, evening: 150, consumption: 50 },
|
||||||
|
greywater: { level: 42 }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(carryOver.greywaterLevel).toBe(42)
|
||||||
|
expect(carryOver.freshwater.morning).toBe(80)
|
||||||
|
expect(carryOver.fuel.morning).toBe(150)
|
||||||
|
expect(carryOver.departure).toBe('Oslo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults greywater to 0 when previous day has none', () => {
|
||||||
|
expect(carryOverFromPreviousDay(null).greywaterLevel).toBe(0)
|
||||||
|
expect(getClosingGreywaterLevel(undefined)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats greywater level as carry-over candidate', () => {
|
||||||
|
expect(
|
||||||
|
hasCarryOverFromPreviousDay({
|
||||||
|
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
greywaterLevel: 15,
|
||||||
|
departure: ''
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -41,12 +41,14 @@ export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
|
|||||||
export interface LogEntryTankSource {
|
export interface LogEntryTankSource {
|
||||||
freshwater?: Partial<TankLevels>
|
freshwater?: Partial<TankLevels>
|
||||||
fuel?: Partial<TankLevels>
|
fuel?: Partial<TankLevels>
|
||||||
|
greywater?: { level?: number }
|
||||||
destination?: string
|
destination?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CarryOverFromPreviousDay {
|
export interface CarryOverFromPreviousDay {
|
||||||
freshwater: TankLevels
|
freshwater: TankLevels
|
||||||
fuel: TankLevels
|
fuel: TankLevels
|
||||||
|
greywaterLevel: number
|
||||||
departure: string
|
departure: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,10 @@ export function formatTankLiters(liters: number): string {
|
|||||||
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
|
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
|
||||||
|
return Number(greywater?.level) || 0
|
||||||
|
}
|
||||||
|
|
||||||
export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankSource | null): { freshwater: TankLevels; fuel: TankLevels } {
|
export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankSource | null): { freshwater: TankLevels; fuel: TankLevels } {
|
||||||
if (!previousEntry) {
|
if (!previousEntry) {
|
||||||
return { freshwater: emptyTankLevels(), fuel: emptyTankLevels() }
|
return { freshwater: emptyTankLevels(), fuel: emptyTankLevels() }
|
||||||
@@ -73,10 +79,16 @@ export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankS
|
|||||||
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
|
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
|
||||||
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
||||||
const departure = previousEntry?.destination?.trim() || ''
|
const departure = previousEntry?.destination?.trim() || ''
|
||||||
|
const greywaterLevel = getClosingGreywaterLevel(previousEntry?.greywater)
|
||||||
|
|
||||||
return { freshwater, fuel, departure }
|
return { freshwater, fuel, greywaterLevel, departure }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
|
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
|
||||||
return carryOver.freshwater.morning > 0 || carryOver.fuel.morning > 0 || carryOver.departure.length > 0
|
return (
|
||||||
|
carryOver.freshwater.morning > 0 ||
|
||||||
|
carryOver.fuel.morning > 0 ||
|
||||||
|
carryOver.greywaterLevel > 0 ||
|
||||||
|
carryOver.departure.length > 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
clampTankLiters,
|
||||||
|
computeEveningTankMaxLiters,
|
||||||
|
computeRefilledTankMaxLiters,
|
||||||
|
extractTankCapacitiesFromYacht,
|
||||||
|
formatTankLitersForInput,
|
||||||
|
parseOptionalTankLiters,
|
||||||
|
tankCapacityInputFromStored
|
||||||
|
} from './tankCapacity.js'
|
||||||
|
|
||||||
|
describe('tankCapacity', () => {
|
||||||
|
it('parses optional liters with comma decimal', () => {
|
||||||
|
expect(parseOptionalTankLiters('200')).toBe(200)
|
||||||
|
expect(parseOptionalTankLiters('12,5')).toBe(12.5)
|
||||||
|
expect(parseOptionalTankLiters('')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects negative or invalid liters', () => {
|
||||||
|
expect(() => parseOptionalTankLiters('-1')).toThrow('invalid_tank_liters')
|
||||||
|
expect(() => parseOptionalTankLiters('abc')).toThrow('invalid_tank_liters')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts capacities from yacht payload', () => {
|
||||||
|
expect(
|
||||||
|
extractTankCapacitiesFromYacht({
|
||||||
|
freshwaterCapacityL: 300,
|
||||||
|
fuelCapacityL: 120,
|
||||||
|
greywaterCapacityL: 80
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
freshwaterCapacityL: 300,
|
||||||
|
fuelCapacityL: 120,
|
||||||
|
greywaterCapacityL: 80
|
||||||
|
})
|
||||||
|
expect(extractTankCapacitiesFromYacht({ name: 'Test' })).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats stored capacity for input', () => {
|
||||||
|
expect(tankCapacityInputFromStored(150)).toBe('150')
|
||||||
|
expect(formatTankLitersForInput(12.5)).toBe('12.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps liters to max when set', () => {
|
||||||
|
expect(clampTankLiters(250, 200)).toBe(200)
|
||||||
|
expect(clampTankLiters(-5, 200)).toBe(0)
|
||||||
|
expect(clampTankLiters(50)).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes refilled max as capacity minus morning', () => {
|
||||||
|
expect(computeRefilledTankMaxLiters('10', 60)).toBe(50)
|
||||||
|
expect(computeRefilledTankMaxLiters('60', 60)).toBeUndefined()
|
||||||
|
expect(computeRefilledTankMaxLiters('10', undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes evening max as morning plus refilled capped by capacity', () => {
|
||||||
|
expect(computeEveningTankMaxLiters('10', '20', 60)).toBe(30)
|
||||||
|
expect(computeEveningTankMaxLiters('40', '40', 60)).toBe(60)
|
||||||
|
expect(computeEveningTankMaxLiters('10', '20')).toBe(30)
|
||||||
|
expect(computeEveningTankMaxLiters('0', '0', 60)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { formatTankLiters } from './logEntryTankLevels.js'
|
||||||
|
|
||||||
|
export interface VesselTankCapacities {
|
||||||
|
freshwaterCapacityL?: number
|
||||||
|
fuelCapacityL?: number
|
||||||
|
greywaterCapacityL?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOptionalTankLiters(input: string): number | undefined {
|
||||||
|
const trimmed = input.trim().replace(',', '.')
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
const parsed = Number(trimmed)
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
throw new Error('invalid_tank_liters')
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTankLitersForInput(liters: number): string {
|
||||||
|
return formatTankLiters(liters)
|
||||||
|
}
|
||||||
|
|
||||||
|
function capacityFromStored(value: unknown): number | undefined {
|
||||||
|
if (value == null || value === '') return undefined
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim().replace(',', '.')
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
const parsed = Number(trimmed)
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) return parsed
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tankCapacityInputFromStored(value: unknown): string {
|
||||||
|
const n = capacityFromStored(value)
|
||||||
|
return n != null ? formatTankLitersForInput(n) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTankCapacitiesFromYacht(decrypted: unknown): VesselTankCapacities {
|
||||||
|
if (!decrypted || typeof decrypted !== 'object') return {}
|
||||||
|
const y = decrypted as Record<string, unknown>
|
||||||
|
const capacities: VesselTankCapacities = {}
|
||||||
|
const fw = capacityFromStored(y.freshwaterCapacityL)
|
||||||
|
const fuel = capacityFromStored(y.fuelCapacityL)
|
||||||
|
const gw = capacityFromStored(y.greywaterCapacityL)
|
||||||
|
if (fw != null) capacities.freshwaterCapacityL = fw
|
||||||
|
if (fuel != null) capacities.fuelCapacityL = fuel
|
||||||
|
if (gw != null) capacities.greywaterCapacityL = gw
|
||||||
|
return capacities
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a liter amount from form state (string). */
|
||||||
|
export function parseTankLitersFromInput(input: string): number {
|
||||||
|
const trimmed = input.trim().replace(',', '.')
|
||||||
|
if (!trimmed) return 0
|
||||||
|
const parsed = Number(trimmed)
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max for refilled amount: remaining capacity after morning level.
|
||||||
|
* Returns undefined when no positive max (no slider).
|
||||||
|
*/
|
||||||
|
export function computeRefilledTankMaxLiters(
|
||||||
|
morningInput: string,
|
||||||
|
tankCapacityL?: number
|
||||||
|
): number | undefined {
|
||||||
|
if (tankCapacityL == null || tankCapacityL <= 0) return undefined
|
||||||
|
const remaining = tankCapacityL - parseTankLitersFromInput(morningInput)
|
||||||
|
if (remaining <= 0) return undefined
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max for evening fill level: morning + refilled, capped by tank capacity when known.
|
||||||
|
* Returns undefined when no positive max (no slider).
|
||||||
|
*/
|
||||||
|
export function computeEveningTankMaxLiters(
|
||||||
|
morningInput: string,
|
||||||
|
refilledInput: string,
|
||||||
|
tankCapacityL?: number
|
||||||
|
): number | undefined {
|
||||||
|
const sum = parseTankLitersFromInput(morningInput) + parseTankLitersFromInput(refilledInput)
|
||||||
|
if (sum <= 0) return undefined
|
||||||
|
|
||||||
|
if (tankCapacityL != null && tankCapacityL > 0) {
|
||||||
|
return Math.min(tankCapacityL, sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp numeric liter value to [0, max] when max is known. */
|
||||||
|
export function clampTankLiters(value: number, maxLiters?: number): number {
|
||||||
|
const clamped = Math.max(0, value)
|
||||||
|
if (maxLiters != null && maxLiters > 0) {
|
||||||
|
return Math.min(clamped, maxLiters)
|
||||||
|
}
|
||||||
|
return clamped
|
||||||
|
}
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
<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>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>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>Foto-Avatarbilder für Skipper und Crew</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>PDF- & CSV-Export</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>PDF- & CSV-Export</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Verschlüsseltes Backup & Wiederherstellung</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Verschlüsseltes Backup & Wiederherstellung</span></div>
|
||||||
|
|||||||
Binary file not shown.
@@ -59,7 +59,7 @@ bump_patch_version() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensure_clean_git_tree() {
|
ensure_clean_git_tree() {
|
||||||
if git diff-index --quiet HEAD -- && [ -z "$(git status --porcelain)" ]; then
|
if [ -z "$(git status --porcelain)" ]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ model User {
|
|||||||
collaborations Collaboration[]
|
collaborations Collaboration[]
|
||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
notificationPrefs UserNotificationPrefs?
|
notificationPrefs UserNotificationPrefs?
|
||||||
|
appearancePrefs UserAppearancePrefs?
|
||||||
}
|
}
|
||||||
|
|
||||||
model PushSubscription {
|
model PushSubscription {
|
||||||
@@ -48,6 +49,15 @@ model UserNotificationPrefs {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserAppearancePrefs {
|
||||||
|
userId String @id
|
||||||
|
theme String @default("auto")
|
||||||
|
colorScheme String @default("auto")
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
model Credential {
|
model Credential {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ function normalizeCredentialLabel(label: unknown): string | null {
|
|||||||
return trimmed.slice(0, 64)
|
return trimmed.slice(0, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_THEMES = new Set(['auto', 'ocean', 'material', 'cupertino'])
|
||||||
|
const VALID_COLOR_SCHEMES = new Set(['auto', 'light', 'dark'])
|
||||||
|
|
||||||
|
function parseThemePreference(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && VALID_THEMES.has(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseColorSchemePreference(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && VALID_COLOR_SCHEMES.has(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
router.post('/register-options', async (req, res) => {
|
router.post('/register-options', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username } = req.body
|
const { username } = req.body
|
||||||
@@ -426,6 +437,57 @@ router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const prefs = await prisma.userAppearancePrefs.findUnique({
|
||||||
|
where: { userId: req.userId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
theme: prefs?.theme ?? 'auto',
|
||||||
|
colorScheme: prefs?.colorScheme ?? 'auto',
|
||||||
|
persisted: prefs != null
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error reading appearance prefs:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const theme = parseThemePreference(req.body?.theme)
|
||||||
|
const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
|
||||||
|
if (!theme || !colorScheme) {
|
||||||
|
return res.status(400).json({ error: 'Invalid theme or colorScheme' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefs = await prisma.userAppearancePrefs.upsert({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
create: {
|
||||||
|
userId: req.userId,
|
||||||
|
theme,
|
||||||
|
colorScheme,
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
theme,
|
||||||
|
colorScheme,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
theme: prefs.theme,
|
||||||
|
colorScheme: prefs.colorScheme,
|
||||||
|
persisted: true
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating appearance prefs:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/profile', requireUser, async (req: any, res) => {
|
router.get('/profile', requireUser, async (req: any, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|||||||
Reference in New Issue
Block a user