Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73e86d28b3 | |||
| ad4721e694 | |||
| 8037b3b63e | |||
| c4cd566da0 | |||
| 3a267905b0 | |||
| c856c2e903 | |||
| b3256d1685 | |||
| 23fc940324 | |||
| 25e1bdded3 | |||
| 6a61c9e06c | |||
| d3683ad6aa | |||
| ef5891ba3f | |||
| d25095bab3 | |||
| 0d16782001 | |||
| b7e2d470a9 | |||
| 520ba766a3 | |||
| c215cd8b15 | |||
| 27c780d2b8 | |||
| aa52948ddc | |||
| 49b4e7b9c3 | |||
| 2d64987ada | |||
| 87973eaa4a |
+3
-2
@@ -17,7 +17,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
||||
<meta name="theme-color" content="#1e293b" />
|
||||
<meta name="theme-color" content="#0b0c10" />
|
||||
<script src="/appearance-bootstrap.js"></script>
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Kapteins Daagbok" />
|
||||
@@ -36,7 +37,7 @@
|
||||
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
|
||||
<title>Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
|
||||
</head>
|
||||
<body style="margin:0;background:#0b0c10;color:#e2e8f0">
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Applies saved appearance classes before CSS/JS bundle loads (prevents wrong flash on PWA).
|
||||
* Logic mirrors client/src/services/appearance.ts + userPreferences.ts.
|
||||
*/
|
||||
(function () {
|
||||
try {
|
||||
var uid = localStorage.getItem('active_userid')
|
||||
var theme = 'auto'
|
||||
var scheme = 'auto'
|
||||
|
||||
if (uid) {
|
||||
theme =
|
||||
localStorage.getItem('user_pref_theme_' + uid) ||
|
||||
localStorage.getItem('active_theme') ||
|
||||
'auto'
|
||||
scheme =
|
||||
localStorage.getItem('user_pref_color_scheme_' + uid) ||
|
||||
localStorage.getItem('active_color_scheme') ||
|
||||
'auto'
|
||||
} else {
|
||||
theme = localStorage.getItem('active_theme') || 'auto'
|
||||
scheme = localStorage.getItem('active_color_scheme') || 'auto'
|
||||
}
|
||||
|
||||
var resolvedTheme = theme
|
||||
if (resolvedTheme !== 'ocean' && resolvedTheme !== 'material' && resolvedTheme !== 'cupertino') {
|
||||
var ua = navigator.userAgent || navigator.vendor || ''
|
||||
if (/iPad|iPhone|iPod|Macintosh/.test(ua)) resolvedTheme = 'cupertino'
|
||||
else if (/Android|Linux/.test(ua)) resolvedTheme = 'material'
|
||||
else resolvedTheme = 'ocean'
|
||||
}
|
||||
|
||||
var resolvedScheme = scheme
|
||||
if (resolvedScheme !== 'light' && resolvedScheme !== 'dark') {
|
||||
resolvedScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
var root = document.documentElement
|
||||
root.classList.add('theme-' + resolvedTheme, 'scheme-' + resolvedScheme)
|
||||
root.style.colorScheme = resolvedScheme
|
||||
} catch (_) {
|
||||
/* ignore storage / matchMedia errors */
|
||||
}
|
||||
})()
|
||||
+114
-8
@@ -8,6 +8,18 @@ body {
|
||||
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) {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
@@ -1046,6 +1058,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
.profile-dl-row dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--app-text);
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
justify-self: start;
|
||||
@@ -1059,8 +1072,6 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
|
||||
.profile-user-id code {
|
||||
font-size: 12px;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -1127,8 +1138,8 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(148, 163, 184, 0.06);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: var(--app-icon-btn-bg);
|
||||
border: 1px solid var(--app-icon-btn-border);
|
||||
}
|
||||
|
||||
.profile-passkey-main {
|
||||
@@ -1241,6 +1252,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
display: block;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--app-input-text);
|
||||
}
|
||||
|
||||
.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 */
|
||||
.track-upload-zone {
|
||||
display: flex;
|
||||
@@ -4264,10 +4368,12 @@ body.app-tour-active .app-tour-target-active {
|
||||
}
|
||||
|
||||
.app-tour-tooltip:not(.centered) {
|
||||
left: max(16px, env(safe-area-inset-left, 0px));
|
||||
right: max(16px, env(safe-area-inset-right, 0px));
|
||||
width: auto;
|
||||
max-width: none;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.app-tour-tooltip:not(.centered).app-tour-tooltip--anchored {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.app-tour-tooltip.centered {
|
||||
|
||||
+78
-22
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import './App.css'
|
||||
import { DialogProvider } from './components/ModalDialog.tsx'
|
||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||
@@ -46,7 +46,7 @@ import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
getStoredDemoFirstEntryId,
|
||||
resolveTourLogbookContext,
|
||||
seedDemoLogbookIfNeeded
|
||||
} from './services/demoLogbook.js'
|
||||
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
||||
@@ -57,7 +57,7 @@ const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
||||
function App() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { confirmLeave } = useUnsavedChangesContext()
|
||||
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
||||
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
||||
@@ -69,6 +69,12 @@ function App() {
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
||||
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
||||
id: activeLogbookId,
|
||||
title: activeLogbookTitle
|
||||
})
|
||||
activeLogbookRef.current = { id: activeLogbookId, title: activeLogbookTitle }
|
||||
|
||||
// Viewer mode for read-only shared links
|
||||
const [isViewerMode, setIsViewerMode] = useState(false)
|
||||
@@ -309,28 +315,66 @@ function App() {
|
||||
setIsAcceptingInvite(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId,
|
||||
setFeedbackOpen: setTourFeedbackOpen
|
||||
})
|
||||
}, [registerNavigation])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && activeLogbookId) {
|
||||
setDemoHighlightEntryId(getStoredDemoFirstEntryId())
|
||||
}
|
||||
}, [isAuthenticated, activeLogbookId])
|
||||
|
||||
const selectLogbook = (id: string, title: string) => {
|
||||
const selectLogbook = useCallback((id: string, title: string) => {
|
||||
setActiveLogbookId(id)
|
||||
setActiveLogbookTitle(title)
|
||||
setActiveTab('logs')
|
||||
setTourSelectedEntryId(null)
|
||||
localStorage.setItem('active_logbook_id', id)
|
||||
localStorage.setItem('active_logbook_title', title)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const ensureTourLogbookOpen = useCallback(async () => {
|
||||
const ctx = await resolveTourLogbookContext(tourLogbookRef.current?.id)
|
||||
if (!ctx) return
|
||||
|
||||
if (activeLogbookRef.current.id !== ctx.logbookId) {
|
||||
selectLogbook(ctx.logbookId, ctx.title)
|
||||
}
|
||||
|
||||
if (ctx.firstEntryId) {
|
||||
setDemoHighlightEntryId(ctx.firstEntryId)
|
||||
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
|
||||
}
|
||||
}, [registerDemoTourContext, selectLogbook])
|
||||
|
||||
useEffect(() => {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId,
|
||||
setFeedbackOpen: setTourFeedbackOpen,
|
||||
setProfileOpen: setShowUserProfile,
|
||||
ensureLogbookForTour: ensureTourLogbookOpen,
|
||||
setLogbookActive: (active) => {
|
||||
if (active) {
|
||||
void ensureTourLogbookOpen()
|
||||
return
|
||||
}
|
||||
|
||||
const { id, title } = activeLogbookRef.current
|
||||
if (id && title) {
|
||||
tourLogbookRef.current = { id, title }
|
||||
}
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setTourSelectedEntryId(null)
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
})
|
||||
}, [ensureTourLogbookOpen, registerNavigation])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !activeLogbookId) return
|
||||
void (async () => {
|
||||
const ctx = await resolveTourLogbookContext()
|
||||
if (!ctx || ctx.logbookId !== activeLogbookId) return
|
||||
if (ctx.firstEntryId) {
|
||||
setDemoHighlightEntryId(ctx.firstEntryId)
|
||||
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
|
||||
}
|
||||
})()
|
||||
}, [isAuthenticated, activeLogbookId, registerDemoTourContext])
|
||||
|
||||
const openLogbookById = useCallback(
|
||||
async (logbookId: string) => {
|
||||
@@ -346,7 +390,7 @@ function App() {
|
||||
}
|
||||
selectLogbook(logbookId, `${logbookId.slice(0, 8)}…`)
|
||||
},
|
||||
[]
|
||||
[selectLogbook]
|
||||
)
|
||||
|
||||
const consumePendingPushLogbook = useCallback(() => {
|
||||
@@ -398,8 +442,20 @@ function App() {
|
||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||
if (savedLogbookId && savedLogbookTitle) {
|
||||
setActiveLogbookId(savedLogbookId)
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
try {
|
||||
const books = await fetchLogbooks()
|
||||
const match = books.find((b) => b.id === savedLogbookId)
|
||||
if (match) {
|
||||
setActiveLogbookId(match.id)
|
||||
setActiveLogbookTitle(match.title)
|
||||
} else {
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
} catch {
|
||||
setActiveLogbookId(savedLogbookId)
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
}
|
||||
}
|
||||
consumePendingPushLogbook()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
getTourStepCopy,
|
||||
getTourTargetSelector,
|
||||
getTourTargetRetryDelay,
|
||||
isCenteredTourStep,
|
||||
useAppTour
|
||||
} from '../context/AppTourContext.tsx'
|
||||
@@ -17,6 +18,20 @@ interface SpotlightRect {
|
||||
|
||||
const TOOLTIP_EDGE_MARGIN = 16
|
||||
const TOOLTIP_ESTIMATED_HEIGHT = 240
|
||||
const TOOLTIP_WIDTH = 420
|
||||
const TARGET_VIEWPORT_MARGIN = 24
|
||||
|
||||
function clampTooltipTop(preferred: number): number {
|
||||
const maxTop = window.innerHeight - TOOLTIP_EDGE_MARGIN - TOOLTIP_ESTIMATED_HEIGHT
|
||||
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(preferred, maxTop))
|
||||
}
|
||||
|
||||
function computeTooltipLeft(spotlight: SpotlightRect): number {
|
||||
const tooltipWidth = Math.min(TOOLTIP_WIDTH, window.innerWidth - TOOLTIP_EDGE_MARGIN * 2)
|
||||
const ideal = spotlight.left + spotlight.width / 2 - tooltipWidth / 2
|
||||
const maxLeft = window.innerWidth - TOOLTIP_EDGE_MARGIN - tooltipWidth
|
||||
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(ideal, maxLeft))
|
||||
}
|
||||
|
||||
function buildCutoutClipPath(rect: SpotlightRect): string {
|
||||
const right = rect.left + rect.width
|
||||
@@ -28,20 +43,36 @@ function computeTooltipTop(spotlight: SpotlightRect): number {
|
||||
const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
|
||||
const below = spotlight.top + spotlight.height + 12
|
||||
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
|
||||
return below
|
||||
return clampTooltipTop(below)
|
||||
}
|
||||
|
||||
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
|
||||
if (above >= TOOLTIP_EDGE_MARGIN) {
|
||||
return above
|
||||
return clampTooltipTop(above)
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
TOOLTIP_EDGE_MARGIN,
|
||||
Math.min(below, viewportBottom - TOOLTIP_ESTIMATED_HEIGHT)
|
||||
return clampTooltipTop(below)
|
||||
}
|
||||
|
||||
function isTargetVisibleInViewport(rect: DOMRect): boolean {
|
||||
return (
|
||||
rect.top >= TARGET_VIEWPORT_MARGIN &&
|
||||
rect.bottom <= window.innerHeight - TARGET_VIEWPORT_MARGIN
|
||||
)
|
||||
}
|
||||
|
||||
function measureSpotlight(el: Element): SpotlightRect | null {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return null
|
||||
const padding = 8
|
||||
return {
|
||||
top: Math.max(8, rect.top - padding),
|
||||
left: Math.max(8, rect.left - padding),
|
||||
width: rect.width + padding * 2,
|
||||
height: rect.height + padding * 2
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppTourOverlay() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
@@ -50,6 +81,7 @@ export default function AppTourOverlay() {
|
||||
currentStepId,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
layoutTick,
|
||||
nextStep,
|
||||
prevStep,
|
||||
skipTour
|
||||
@@ -65,7 +97,10 @@ export default function AppTourOverlay() {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const updateSpotlight = () => {
|
||||
if (cancelled) return
|
||||
const selector = getTourTargetSelector(currentStepId)
|
||||
if (!selector) {
|
||||
setSpotlight(null)
|
||||
@@ -76,27 +111,38 @@ export default function AppTourOverlay() {
|
||||
setSpotlight(null)
|
||||
return
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
const padding = 8
|
||||
setSpotlight({
|
||||
top: Math.max(8, rect.top - padding),
|
||||
left: Math.max(8, rect.left - padding),
|
||||
width: rect.width + padding * 2,
|
||||
height: rect.height + padding * 2
|
||||
})
|
||||
if (!isTargetVisibleInViewport(rect)) {
|
||||
el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' })
|
||||
window.requestAnimationFrame(() => {
|
||||
if (cancelled) return
|
||||
const next = measureSpotlight(el)
|
||||
setSpotlight(next)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setSpotlight(measureSpotlight(el))
|
||||
}
|
||||
|
||||
updateSpotlight()
|
||||
window.addEventListener('resize', updateSpotlight)
|
||||
window.addEventListener('scroll', updateSpotlight, true)
|
||||
const timer = window.setTimeout(updateSpotlight, 120)
|
||||
|
||||
const retryDelays =
|
||||
currentStepId === 'entry_track'
|
||||
? [400, 700, 1100, 1600]
|
||||
: [getTourTargetRetryDelay(currentStepId), 120, 280, 480]
|
||||
const timers = retryDelays.map((delay) => window.setTimeout(updateSpotlight, delay))
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
cancelled = true
|
||||
for (const timer of timers) window.clearTimeout(timer)
|
||||
window.removeEventListener('resize', updateSpotlight)
|
||||
window.removeEventListener('scroll', updateSpotlight, true)
|
||||
}
|
||||
}, [currentStepId, isActive])
|
||||
}, [currentStepId, isActive, layoutTick])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
@@ -132,9 +178,17 @@ export default function AppTourOverlay() {
|
||||
const tooltipStyle = centered
|
||||
? undefined
|
||||
: spotlight
|
||||
? { top: computeTooltipTop(spotlight) }
|
||||
? { top: computeTooltipTop(spotlight), left: computeTooltipLeft(spotlight) }
|
||||
: { top: '20%' }
|
||||
|
||||
const tooltipClassName = [
|
||||
'app-tour-tooltip',
|
||||
centered ? 'centered' : '',
|
||||
!centered && spotlight ? 'app-tour-tooltip--anchored' : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const backdropStyle = spotlight && !centered
|
||||
? { clipPath: buildCutoutClipPath(spotlight) }
|
||||
: 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')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
@@ -31,7 +31,9 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId,
|
||||
setFeedbackOpen: () => {}
|
||||
setFeedbackOpen: () => {},
|
||||
setLogbookActive: () => {},
|
||||
setProfileOpen: () => {}
|
||||
})
|
||||
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
|
||||
|
||||
|
||||
@@ -365,6 +365,11 @@ export default function LogEntriesList({
|
||||
)
|
||||
}
|
||||
|
||||
const tourFirstEntryId =
|
||||
highlightEntryId && entries.some((e) => e.id === highlightEntryId)
|
||||
? highlightEntryId
|
||||
: entries[0]?.id ?? null
|
||||
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="section-title-bar mb-6">
|
||||
@@ -402,7 +407,7 @@ export default function LogEntriesList({
|
||||
<div
|
||||
key={item.id}
|
||||
className="logbook-card glass"
|
||||
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
|
||||
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
|
||||
onClick={() => setSelectedEntryId(item.id)}
|
||||
>
|
||||
<div className="card-icon">
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
hasAnySignature
|
||||
} from '../utils/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 CourseDialInput from './CourseDialInput.tsx'
|
||||
import { degreesToCardinal } from '../utils/courseAngle.js'
|
||||
@@ -42,6 +42,14 @@ import {
|
||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||
import TankLiterInput from './TankLiterInput.tsx'
|
||||
import {
|
||||
computeEveningTankMaxLiters,
|
||||
computeRefilledTankMaxLiters,
|
||||
extractTankCapacitiesFromYacht,
|
||||
formatTankLitersForInput,
|
||||
type VesselTankCapacities
|
||||
} from '../utils/tankCapacity.js'
|
||||
|
||||
function emptyTankLevels() {
|
||||
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
||||
@@ -50,6 +58,7 @@ function emptyTankLevels() {
|
||||
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
|
||||
const fw = (decrypted.freshwater 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 trackSpeedMax = decrypted.trackSpeedMaxKn
|
||||
const trackSpeedAvg = decrypted.trackSpeedAvgKn
|
||||
@@ -72,6 +81,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
||||
evening: fuel.evening || 0,
|
||||
consumption: fuel.consumption ?? 0
|
||||
},
|
||||
greywater: gw ? { level: gw.level || 0 } : undefined,
|
||||
trackDistanceNm:
|
||||
trackDistance != null && trackDistance !== ''
|
||||
? parseFloat(String(trackDistance))
|
||||
@@ -145,6 +155,9 @@ export default function LogEntryEditor({
|
||||
const [fuelEvening, setFuelEvening] = useState('0')
|
||||
const [fuelConsumption, setFuelConsumption] = useState('0')
|
||||
|
||||
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
||||
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
||||
|
||||
// Signatures
|
||||
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||
@@ -202,6 +215,7 @@ export default function LogEntryEditor({
|
||||
const contentReadyRef = useRef(false)
|
||||
const lastSignatureAlertHashRef = useRef<string | null>(null)
|
||||
const skipCrewSignClearRef = useRef(false)
|
||||
const entryHashSeqRef = useRef(0)
|
||||
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
|
||||
|
||||
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
|
||||
@@ -248,6 +262,7 @@ export default function LogEntryEditor({
|
||||
evening: parseFloat(fuelEvening) || 0,
|
||||
consumption: parseFloat(fuelConsumption) || 0
|
||||
},
|
||||
greywater: { level: parseFloat(greywaterLevel) || 0 },
|
||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||
@@ -258,6 +273,7 @@ export default function LogEntryEditor({
|
||||
date, dayOfTravel, departure, destination,
|
||||
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||
greywaterLevel,
|
||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||
events
|
||||
])
|
||||
@@ -267,6 +283,38 @@ export default function LogEntryEditor({
|
||||
[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 payload = buildPayloadForSigning()
|
||||
return JSON.stringify({
|
||||
@@ -304,13 +352,7 @@ export default function LogEntryEditor({
|
||||
}
|
||||
|
||||
const hasPendingEventForm = useMemo(() => {
|
||||
if (!evTime.trim()) return false
|
||||
const draft = buildEventFromForm()
|
||||
if (editingEventIndex !== null) {
|
||||
const original = events[editingEventIndex]
|
||||
return original ? !logEventsEqual(draft, original) : false
|
||||
}
|
||||
return true
|
||||
return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events)
|
||||
}, [
|
||||
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
|
||||
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
||||
@@ -331,16 +373,27 @@ export default function LogEntryEditor({
|
||||
onBack()
|
||||
}
|
||||
|
||||
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
|
||||
const persistEntryToDb = useCallback(async (
|
||||
options?: LogEvent[] | {
|
||||
eventsOverride?: LogEvent[]
|
||||
signSkipper?: SignatureValue | ''
|
||||
signCrew?: SignatureValue | ''
|
||||
}
|
||||
) => {
|
||||
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()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const entryData = {
|
||||
...buildPayloadForSigning(eventsOverride),
|
||||
signSkipper: normalizedSerializedSignature(signSkipper),
|
||||
signCrew: normalizedSerializedSignature(signCrew)
|
||||
signSkipper: normalizedSerializedSignature(skipperToSave),
|
||||
signCrew: normalizedSerializedSignature(crewToSave)
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(entryData, masterKey)
|
||||
@@ -368,9 +421,14 @@ export default function LogEntryEditor({
|
||||
|
||||
setSavedFingerprint(JSON.stringify({
|
||||
...buildPayloadForSigning(eventsOverride),
|
||||
signSkipper: fingerprintSignature(signSkipper),
|
||||
signCrew: fingerprintSignature(signCrew)
|
||||
signSkipper: fingerprintSignature(skipperToSave),
|
||||
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
|
||||
])
|
||||
@@ -398,9 +456,11 @@ export default function LogEntryEditor({
|
||||
}, [logbookId])
|
||||
|
||||
useEffect(() => {
|
||||
const seq = ++entryHashSeqRef.current
|
||||
let cancelled = false
|
||||
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
|
||||
if (!cancelled) setEntryHash(hash)
|
||||
if (cancelled || seq !== entryHashSeqRef.current) return
|
||||
setEntryHash(hash)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [buildPayloadForSigning])
|
||||
@@ -471,6 +531,7 @@ export default function LogEntryEditor({
|
||||
role: 'skipper'
|
||||
})
|
||||
setSignSkipper(signature)
|
||||
entryHashSeqRef.current += 1
|
||||
setEntryHash(hash)
|
||||
lockedContentHashRef.current = hash
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||
@@ -489,6 +550,7 @@ export default function LogEntryEditor({
|
||||
role: 'crew'
|
||||
})
|
||||
setSignCrew(signature)
|
||||
entryHashSeqRef.current += 1
|
||||
setEntryHash(hash)
|
||||
lockedContentHashRef.current = hash
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
|
||||
@@ -512,11 +574,59 @@ export default function LogEntryEditor({
|
||||
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
||||
}, [fuelMorning, fuelRefilled, fuelEvening])
|
||||
|
||||
// Load Yacht Sails
|
||||
const fwRefilledNoCapacity =
|
||||
(tankCapacities.freshwaterCapacityL ?? 0) > 0 && fwRefilledMax == null
|
||||
const fuelRefilledNoCapacity =
|
||||
(tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null
|
||||
|
||||
useEffect(() => {
|
||||
async function loadYachtSails() {
|
||||
if (readOnly && preloadedYacht?.sails) {
|
||||
setYachtSails(preloadedYacht.sails)
|
||||
const refilled = parseFloat(fwRefilled) || 0
|
||||
if (fwRefilledMax == null) {
|
||||
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
|
||||
}
|
||||
try {
|
||||
@@ -526,16 +636,19 @@ export default function LogEntryEditor({
|
||||
const yacht = await db.yachts.get(logbookId)
|
||||
if (yacht) {
|
||||
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
|
||||
if (decrypted && decrypted.sails && Array.isArray(decrypted.sails)) {
|
||||
setYachtSails(decrypted.sails)
|
||||
if (decrypted) {
|
||||
if (decrypted.sails && Array.isArray(decrypted.sails)) {
|
||||
setYachtSails(decrypted.sails)
|
||||
}
|
||||
setTankCapacities(extractTankCapacitiesFromYacht(decrypted))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load yacht sails in editor:', err)
|
||||
console.error('Failed to load yacht meta in editor:', err)
|
||||
}
|
||||
}
|
||||
loadYachtSails()
|
||||
}, [logbookId, preloadedYacht])
|
||||
loadYachtMeta()
|
||||
}, [logbookId, preloadedYacht, readOnly])
|
||||
|
||||
// Load entry details
|
||||
useEffect(() => {
|
||||
@@ -565,6 +678,11 @@ export default function LogEntryEditor({
|
||||
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
||||
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
|
||||
}
|
||||
if (preloadedEntry.greywater) {
|
||||
setGreywaterLevel(String(preloadedEntry.greywater.level || 0))
|
||||
} else {
|
||||
setGreywaterLevel('0')
|
||||
}
|
||||
|
||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||
@@ -598,6 +716,11 @@ export default function LogEntryEditor({
|
||||
setFuelEvening(String(decrypted.fuel.evening || 0))
|
||||
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
|
||||
}
|
||||
if (decrypted.greywater) {
|
||||
setGreywaterLevel(String(decrypted.greywater.level || 0))
|
||||
} else {
|
||||
setGreywaterLevel('0')
|
||||
}
|
||||
|
||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||
@@ -921,10 +1044,23 @@ export default function LogEntryEditor({
|
||||
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 = () => {
|
||||
if (!signSkipper) return
|
||||
skipCrewSignClearRef.current = true
|
||||
setSignSkipper('')
|
||||
resolveSignaturesAfterContentChange(true)
|
||||
}
|
||||
|
||||
const handleEditEvent = (index: number) => {
|
||||
@@ -1014,11 +1150,20 @@ export default function LogEntryEditor({
|
||||
if (readOnly) return
|
||||
|
||||
let eventsToSave = events
|
||||
let signaturesForSave: { signSkipper: SignatureValue | ''; signCrew: SignatureValue | '' } | undefined
|
||||
|
||||
if (hasPendingEventForm) {
|
||||
const isEdit = editingEventIndex !== null
|
||||
if (isEdit && signSkipper) {
|
||||
markSkipperSignatureClearedForEventChange()
|
||||
const resolved = resolveSignaturesAfterContentChange(isEdit)
|
||||
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())
|
||||
setEvents(eventsToSave)
|
||||
@@ -1032,7 +1177,10 @@ export default function LogEntryEditor({
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
await persistEntryToDb(eventsToSave)
|
||||
await persistEntryToDb({
|
||||
eventsOverride: eventsToSave,
|
||||
...signaturesForSave
|
||||
})
|
||||
|
||||
setSuccess(true)
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
@@ -1170,41 +1318,35 @@ export default function LogEntryEditor({
|
||||
<h3>{t('logs.freshwater')}</h3>
|
||||
</div>
|
||||
<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">
|
||||
<label>{t('logs.morning')}</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>
|
||||
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text consumption-value"
|
||||
@@ -1212,6 +1354,7 @@ export default function LogEntryEditor({
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-readonly="true"
|
||||
title={tankCapacityTooltip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1224,41 +1367,35 @@ export default function LogEntryEditor({
|
||||
<h3>{t('logs.fuel')}</h3>
|
||||
</div>
|
||||
<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">
|
||||
<label>{t('logs.morning')}</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>
|
||||
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text consumption-value"
|
||||
@@ -1266,11 +1403,12 @@ export default function LogEntryEditor({
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-readonly="true"
|
||||
title={tankCapacityTooltip}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.fuel_per_motor_hour')}</label>
|
||||
<label title={tankCapacityTooltip}>{t('logs.fuel_per_motor_hour')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text consumption-value"
|
||||
@@ -1282,10 +1420,30 @@ export default function LogEntryEditor({
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-readonly="true"
|
||||
title={tankCapacityTooltip}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Section 3: Event Journal Entries */}
|
||||
|
||||
@@ -314,6 +314,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
onClick={onOpenProfile}
|
||||
title={t('dashboard.open_profile', { name: username })}
|
||||
aria-label={t('dashboard.open_profile', { name: username })}
|
||||
data-tour="nav-profile"
|
||||
>
|
||||
<User size={18} aria-hidden="true" />
|
||||
<span className="skipper-badge__name">{username}</span>
|
||||
|
||||
@@ -6,6 +6,12 @@ import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { apiFetch } from '../services/api.js'
|
||||
import {
|
||||
enableCollaboratorChangePush,
|
||||
isCollaboratorPushActive,
|
||||
isPushSupported
|
||||
} from '../services/pushNotifications.js'
|
||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||
|
||||
interface SettingsFormProps {
|
||||
logbookId?: string | null
|
||||
@@ -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 () => {
|
||||
if (!logbookId) return
|
||||
setGeneratingInvite(true)
|
||||
@@ -175,6 +218,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
|
||||
setInviteLink(link)
|
||||
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
|
||||
await promptPushAfterInviteCreated()
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to generate invite:', err)
|
||||
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>
|
||||
) : profile ? (
|
||||
<>
|
||||
<div data-tour="profile-preferences">
|
||||
<section className="form-card">
|
||||
<div className="form-header">
|
||||
<User size={24} className="form-icon" />
|
||||
@@ -484,6 +485,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</section>
|
||||
|
||||
<UserProfilePreferences userId={profile.userId} />
|
||||
</div>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
|
||||
import { parseOptionalTankLiters, tankCapacityInputFromStored } from '../utils/tankCapacity.js'
|
||||
|
||||
interface VesselFormProps {
|
||||
logbookId: string
|
||||
@@ -47,6 +48,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
const [mmsi, setMmsi] = useState('')
|
||||
const [sails, setSails] = useState<string[]>([])
|
||||
const [newSailName, setNewSailName] = useState('')
|
||||
const [freshwaterCapacityL, setFreshwaterCapacityL] = useState('')
|
||||
const [fuelCapacityL, setFuelCapacityL] = useState('')
|
||||
const [greywaterCapacityL, setGreywaterCapacityL] = useState('')
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [photo, setPhoto] = useState<string | null>(null)
|
||||
@@ -78,6 +82,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
setMmsi(preloadedData.mmsi || '')
|
||||
setSails(preloadedData.sails || [])
|
||||
setPhoto(preloadedData.photo || null)
|
||||
setFreshwaterCapacityL(tankCapacityInputFromStored(preloadedData.freshwaterCapacityL))
|
||||
setFuelCapacityL(tankCapacityInputFromStored(preloadedData.fuelCapacityL))
|
||||
setGreywaterCapacityL(tankCapacityInputFromStored(preloadedData.greywaterCapacityL))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,6 +110,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
setMmsi(decrypted.mmsi || '')
|
||||
setSails(decrypted.sails || [])
|
||||
setPhoto(decrypted.photo || null)
|
||||
setFreshwaterCapacityL(tankCapacityInputFromStored(decrypted.freshwaterCapacityL))
|
||||
setFuelCapacityL(tankCapacityInputFromStored(decrypted.fuelCapacityL))
|
||||
setGreywaterCapacityL(tankCapacityInputFromStored(decrypted.greywaterCapacityL))
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -201,12 +211,19 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
let parsedLengthM: number | undefined
|
||||
let parsedDraftM: number | undefined
|
||||
let parsedAirDraftM: number | undefined
|
||||
let parsedFreshwaterCapacityL: number | undefined
|
||||
let parsedFuelCapacityL: number | undefined
|
||||
let parsedGreywaterCapacityL: number | undefined
|
||||
try {
|
||||
parsedLengthM = parseOptionalMetricMeters(lengthM)
|
||||
parsedDraftM = parseOptionalMetricMeters(draftM)
|
||||
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
|
||||
} catch {
|
||||
setError(t('vessel.invalid_metric'))
|
||||
parsedFreshwaterCapacityL = parseOptionalTankLiters(freshwaterCapacityL)
|
||||
parsedFuelCapacityL = parseOptionalTankLiters(fuelCapacityL)
|
||||
parsedGreywaterCapacityL = parseOptionalTankLiters(greywaterCapacityL)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
setError(msg === 'invalid_tank_liters' ? t('vessel.invalid_tank_liters') : t('vessel.invalid_metric'))
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
@@ -217,6 +234,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
lengthM: parsedLengthM,
|
||||
draftM: parsedDraftM,
|
||||
airDraftM: parsedAirDraftM,
|
||||
freshwaterCapacityL: parsedFreshwaterCapacityL,
|
||||
fuelCapacityL: parsedFuelCapacityL,
|
||||
greywaterCapacityL: parsedGreywaterCapacityL,
|
||||
homePort: homePort.trim(),
|
||||
charterCompany: charterCompany.trim(),
|
||||
owner: owner.trim(),
|
||||
@@ -480,6 +500,49 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vessel-tanks-section">
|
||||
<h3>{t('vessel.tanks_section')}</h3>
|
||||
<p className="vessel-tanks-help">{t('vessel.tanks_help')}</p>
|
||||
<div className="vessel-tanks-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.freshwater_capacity_l')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={freshwaterCapacityL}
|
||||
onChange={(e) => setFreshwaterCapacityL(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.fuel_capacity_l')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={fuelCapacityL}
|
||||
onChange={(e) => setFuelCapacityL(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.greywater_capacity_l')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={greywaterCapacityL}
|
||||
onChange={(e) => setGreywaterCapacityL(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sails-section">
|
||||
<h3>{t('vessel.sails_list')}</h3>
|
||||
<p className="help-text">{t('vessel.sails_help')}</p>
|
||||
|
||||
@@ -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_stats'
|
||||
| 'nav_feedback'
|
||||
| 'nav_profile'
|
||||
| 'profile_preferences'
|
||||
| 'finish'
|
||||
|
||||
interface TourNavigation {
|
||||
setActiveTab: (tab: AppTab) => void
|
||||
setSelectedEntryId: (entryId: string | null) => void
|
||||
setFeedbackOpen: (open: boolean) => void
|
||||
setLogbookActive: (active: boolean) => void
|
||||
setProfileOpen: (open: boolean) => void
|
||||
ensureLogbookForTour?: () => Promise<void>
|
||||
}
|
||||
|
||||
interface DemoTourContext {
|
||||
@@ -47,6 +52,7 @@ interface AppTourContextValue {
|
||||
currentStepId: TourStepId | null
|
||||
currentStepIndex: number
|
||||
totalSteps: number
|
||||
layoutTick: number
|
||||
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
|
||||
stopTour: () => void
|
||||
restartTour: () => void
|
||||
@@ -58,7 +64,7 @@ interface AppTourContextValue {
|
||||
requestStartAfterLogin: () => void
|
||||
}
|
||||
|
||||
const FULL_STEP_ORDER: TourStepId[] = [
|
||||
export const FULL_STEP_ORDER: TourStepId[] = [
|
||||
'welcome',
|
||||
'nav_logs',
|
||||
'entry_list',
|
||||
@@ -68,12 +74,33 @@ const FULL_STEP_ORDER: TourStepId[] = [
|
||||
'nav_crew',
|
||||
'nav_stats',
|
||||
'nav_feedback',
|
||||
'nav_profile',
|
||||
'profile_preferences',
|
||||
'finish'
|
||||
]
|
||||
|
||||
/** Public demo has no stats/feedback UI — skip those steps. */
|
||||
const DEMO_EXCLUDED_STEPS: TourStepId[] = ['nav_stats', 'nav_feedback']
|
||||
const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter((id) => !DEMO_EXCLUDED_STEPS.includes(id))
|
||||
/** Public demo has no stats/feedback/profile UI — skip those steps. */
|
||||
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
|
||||
'nav_stats',
|
||||
'nav_feedback',
|
||||
'nav_profile',
|
||||
'profile_preferences'
|
||||
]
|
||||
|
||||
export const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter(
|
||||
(id) => !DEMO_EXCLUDED_STEPS.includes(id)
|
||||
)
|
||||
|
||||
const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
|
||||
'nav_logs',
|
||||
'entry_list',
|
||||
'entry_open',
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'nav_crew',
|
||||
'nav_stats',
|
||||
'nav_feedback'
|
||||
])
|
||||
|
||||
function getStepOrder(demoMode: boolean): TourStepId[] {
|
||||
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
|
||||
@@ -87,7 +114,28 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
||||
nav_vessel: '[data-tour="nav-vessel"]',
|
||||
nav_crew: '[data-tour="nav-crew"]',
|
||||
nav_stats: '[data-tour="stats-dashboard"]',
|
||||
nav_feedback: '[data-tour="feedback-form"]'
|
||||
nav_feedback: '[data-tour="feedback-form"]',
|
||||
nav_profile: '[data-tour="nav-profile"]',
|
||||
profile_preferences: '[data-tour="profile-preferences"]'
|
||||
}
|
||||
|
||||
/** Whether a tour step opens the first log entry editor (not the list card). */
|
||||
export function tourStepOpensEntry(stepId: TourStepId): boolean {
|
||||
return stepId === 'entry_track'
|
||||
}
|
||||
|
||||
export function getTourTargetDelay(stepId: TourStepId): number {
|
||||
if (stepId === 'entry_track') return 400
|
||||
if (stepId === 'nav_feedback') return 180
|
||||
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Extra scroll attempts while async UI (e.g. entry editor) mounts. */
|
||||
export function getTourScrollRetryDelays(stepId: TourStepId): number[] {
|
||||
if (stepId === 'entry_track') return [400, 700, 1100, 1600]
|
||||
const initial = getTourTargetDelay(stepId)
|
||||
return initial > 0 ? [initial] : [0]
|
||||
}
|
||||
|
||||
const AppTourContext = createContext<AppTourContextValue | null>(null)
|
||||
@@ -97,6 +145,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
|
||||
const [isDemoTour, setIsDemoTour] = useState(false)
|
||||
const [layoutTick, setLayoutTick] = useState(0)
|
||||
const navigationRef = useRef<TourNavigation | null>(null)
|
||||
const demoContextRef = useRef<DemoTourContext | null>(null)
|
||||
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
|
||||
@@ -112,13 +161,24 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
const nav = navigationRef.current
|
||||
if (!nav) return
|
||||
|
||||
if (LOGBOOK_TOUR_STEPS.has(stepId)) {
|
||||
nav.setProfileOpen(false)
|
||||
nav.setLogbookActive(true)
|
||||
}
|
||||
|
||||
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
|
||||
nav.setActiveTab('logs')
|
||||
}
|
||||
if (stepId === 'entry_open' || stepId === 'entry_track') {
|
||||
|
||||
if (stepId === 'entry_list' || stepId === 'entry_open') {
|
||||
nav.setSelectedEntryId(null)
|
||||
} else if (tourStepOpensEntry(stepId)) {
|
||||
const firstEntryId = resolveFirstEntryId()
|
||||
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
|
||||
} else if (LOGBOOK_TOUR_STEPS.has(stepId)) {
|
||||
nav.setSelectedEntryId(null)
|
||||
}
|
||||
|
||||
if (stepId === 'nav_vessel') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('vessel')
|
||||
@@ -137,19 +197,34 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
} else {
|
||||
nav.setFeedbackOpen(false)
|
||||
}
|
||||
|
||||
if (stepId === 'nav_profile') {
|
||||
nav.setProfileOpen(false)
|
||||
nav.setLogbookActive(false)
|
||||
}
|
||||
if (stepId === 'profile_preferences') {
|
||||
nav.setLogbookActive(false)
|
||||
nav.setProfileOpen(true)
|
||||
}
|
||||
}, [resolveFirstEntryId])
|
||||
|
||||
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
|
||||
if (!stepId) return
|
||||
const selector = TARGET_BY_STEP[stepId]
|
||||
if (!selector) return
|
||||
const delayMs = stepId === 'nav_feedback' ? 180 : 0
|
||||
window.setTimeout(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const el = document.querySelector(selector)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
||||
})
|
||||
}, delayMs)
|
||||
|
||||
for (const delayMs of getTourScrollRetryDelays(stepId)) {
|
||||
window.setTimeout(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const el = document.querySelector(selector)
|
||||
el?.scrollIntoView({
|
||||
behavior: stepId === 'entry_track' ? 'instant' : 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
})
|
||||
})
|
||||
}, delayMs)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
|
||||
@@ -173,6 +248,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
|
||||
const nav = navigationRef.current
|
||||
if (nav && !tourModeRef.current.demoMode) {
|
||||
nav.setProfileOpen(false)
|
||||
nav.setLogbookActive(true)
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('stats')
|
||||
}
|
||||
@@ -183,6 +260,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
tourModeRef.current = { demoMode: false }
|
||||
navigationRef.current?.setFeedbackOpen(false)
|
||||
navigationRef.current?.setProfileOpen(false)
|
||||
setIsDemoTour(false)
|
||||
setIsActive(false)
|
||||
setStepIndex(0)
|
||||
@@ -213,8 +291,25 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
if (!isActive) return
|
||||
const stepId = getStepOrder(isDemoTour)[stepIndex]
|
||||
if (!stepId) return
|
||||
applyStepSideEffects(stepId)
|
||||
scrollToCurrentTarget(stepId)
|
||||
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
if (LOGBOOK_TOUR_STEPS.has(stepId) && !isDemoTour) {
|
||||
await navigationRef.current?.ensureLogbookForTour?.()
|
||||
}
|
||||
if (cancelled) return
|
||||
applyStepSideEffects(stepId)
|
||||
scrollToCurrentTarget(stepId)
|
||||
setLayoutTick((tick) => tick + 1)
|
||||
window.setTimeout(() => {
|
||||
if (!cancelled) setLayoutTick((tick) => tick + 1)
|
||||
}, 150)
|
||||
}
|
||||
void run()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
||||
|
||||
const restartTour = useCallback(() => {
|
||||
@@ -257,6 +352,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
currentStepId,
|
||||
currentStepIndex: stepIndex,
|
||||
totalSteps: stepOrder.length,
|
||||
layoutTick,
|
||||
startTour,
|
||||
stopTour,
|
||||
restartTour,
|
||||
@@ -281,6 +377,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
startTour,
|
||||
stepIndex,
|
||||
stepOrder.length,
|
||||
layoutTick,
|
||||
stopTour
|
||||
]
|
||||
)
|
||||
@@ -321,3 +418,10 @@ export function getTourTargetSelector(stepId: TourStepId | null): string | null
|
||||
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
|
||||
return stepId === 'welcome' || stepId === 'finish'
|
||||
}
|
||||
|
||||
export function getTourTargetRetryDelay(stepId: TourStepId | null): number {
|
||||
if (stepId === 'entry_track') return 400
|
||||
if (stepId === 'profile_preferences') return 300
|
||||
if (stepId === 'nav_profile') return 200
|
||||
return 120
|
||||
}
|
||||
|
||||
@@ -117,7 +117,13 @@
|
||||
"no_sails": "Keine Segel hinterlegt.",
|
||||
"photo_add": "Foto hinzufügen",
|
||||
"photo_change": "Foto ändern",
|
||||
"photo_delete": "Foto löschen"
|
||||
"photo_delete": "Foto löschen",
|
||||
"tanks_section": "Tanks (Fassungsvermögen)",
|
||||
"tanks_help": "Optional in Liter — ermöglicht Slider im Journal bei bekannten Tankgrößen.",
|
||||
"freshwater_capacity_l": "Trinkwasser (Liter)",
|
||||
"fuel_capacity_l": "Treibstoff (Liter)",
|
||||
"greywater_capacity_l": "Grauwasser (Liter)",
|
||||
"invalid_tank_liters": "Ungültiger Zahlenwert — bitte Liter als Zahl eingeben (z. B. 200)."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logbuch-Journal",
|
||||
@@ -138,6 +144,10 @@
|
||||
"route": "Reise von/nach",
|
||||
"freshwater": "Frischwasser (Liter)",
|
||||
"fuel": "Treibstoff / Fuel (Liter)",
|
||||
"greywater": "Grauwasser (Liter)",
|
||||
"greywater_level": "Füllstand",
|
||||
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||
"tank_capacity_tooltip": "Wenn in den Schiffsdaten die Tank-Fassungsvermögen (Liter) hinterlegt sind, kannst du Füllstände hier per Slider eingeben.",
|
||||
"morning": "Stand morgens",
|
||||
"refilled": "Nachgefüllt",
|
||||
"evening": "Stand abends",
|
||||
@@ -472,6 +482,12 @@
|
||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
||||
"deleting_account": "Konto wird gelöscht…",
|
||||
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
|
||||
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
|
||||
"invite_push_prompt_ios_message": "Sobald Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), dann Push im Benutzerprofil aktivieren.",
|
||||
"invite_push_prompt_enable": "Jetzt aktivieren",
|
||||
"invite_push_prompt_later": "Später",
|
||||
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
||||
"backup_title": "Backup & Wiederherstellung",
|
||||
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
|
||||
"backup_export_title": "Backup erstellen",
|
||||
@@ -672,9 +688,17 @@
|
||||
"title": "Feedback senden",
|
||||
"body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken – auch nach der Tour jederzeit über das Symbol oben rechts."
|
||||
},
|
||||
"nav_profile": {
|
||||
"title": "Dein Benutzerprofil",
|
||||
"body": "Über den Skipper-Button oben erreichst du dein persönliches Profil – unabhängig vom aktuellen Logbuch."
|
||||
},
|
||||
"profile_preferences": {
|
||||
"title": "Konto & Darstellung",
|
||||
"body": "Hier verwaltest du deine Konto-Identität, Theme und Hell/Dunkel-Modus. Die App-Tour kannst du jederzeit erneut starten. Passkeys und Sicherheitseinstellungen findest du weiter unten im Profil."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Alles klar!",
|
||||
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
|
||||
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit im Benutzerprofil erneut starten. Gute Fahrt!"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -117,7 +117,13 @@
|
||||
"no_sails": "No sails defined.",
|
||||
"photo_add": "Add Photo",
|
||||
"photo_change": "Change Photo",
|
||||
"photo_delete": "Delete Photo"
|
||||
"photo_delete": "Delete Photo",
|
||||
"tanks_section": "Tanks (capacity)",
|
||||
"tanks_help": "Optional, in liters — enables sliders in the journal when tank sizes are known.",
|
||||
"freshwater_capacity_l": "Freshwater (liters)",
|
||||
"fuel_capacity_l": "Fuel (liters)",
|
||||
"greywater_capacity_l": "Greywater (liters)",
|
||||
"invalid_tank_liters": "Invalid number — please enter capacity in liters (e.g. 200)."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logbook Journal",
|
||||
@@ -138,6 +144,10 @@
|
||||
"route": "Route / Journey",
|
||||
"freshwater": "Freshwater (Liters)",
|
||||
"fuel": "Fuel (Liters)",
|
||||
"greywater": "Greywater (Liters)",
|
||||
"greywater_level": "Fill level",
|
||||
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||
"tank_capacity_tooltip": "If tank capacities (liters) are set in vessel master data, you can enter fill levels here using sliders.",
|
||||
"morning": "Morning Level",
|
||||
"refilled": "Refilled",
|
||||
"evening": "Evening Level",
|
||||
@@ -472,6 +482,12 @@
|
||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
||||
"deleting_account": "Deleting account…",
|
||||
"invite_push_prompt_title": "Enable push notifications?",
|
||||
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
|
||||
"invite_push_prompt_ios_message": "When crew members sync changes, you can get push notifications. On iPhone/iPad: add the app to your Home Screen (iOS 16.4+), then enable push in your user profile.",
|
||||
"invite_push_prompt_enable": "Enable now",
|
||||
"invite_push_prompt_later": "Later",
|
||||
"invite_push_prompt_success": "Push notifications are active on this device.",
|
||||
"backup_title": "Backup & restore",
|
||||
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
|
||||
"backup_export_title": "Create backup",
|
||||
@@ -672,9 +688,17 @@
|
||||
"title": "Send feedback",
|
||||
"body": "Use this form to report bugs, ideas, or general feedback to the project team — you can also open it anytime later via the icon in the top right."
|
||||
},
|
||||
"nav_profile": {
|
||||
"title": "Your user profile",
|
||||
"body": "Tap the skipper button at the top to open your personal profile — independent of the current logbook."
|
||||
},
|
||||
"profile_preferences": {
|
||||
"title": "Account & appearance",
|
||||
"body": "Manage your account identity, theme, and light/dark mode here. You can restart the app tour anytime. Passkeys and security settings are further down on the profile page."
|
||||
},
|
||||
"finish": {
|
||||
"title": "You're all set!",
|
||||
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!"
|
||||
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime from your user profile. Fair winds!"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -100,12 +100,10 @@ code,
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
applyAppearanceToDocument,
|
||||
resolveAppTheme,
|
||||
resolveColorScheme,
|
||||
type AppTheme,
|
||||
type ResolvedColorScheme
|
||||
} from './appearance.js'
|
||||
import { setColorSchemePreference } from './userPreferences.js'
|
||||
|
||||
const USER_ID = 'appearance-test-user'
|
||||
|
||||
const COMBOS: Array<{ theme: AppTheme; scheme: ResolvedColorScheme }> = [
|
||||
{ theme: 'ocean', scheme: 'dark' },
|
||||
{ theme: 'ocean', scheme: 'light' },
|
||||
{ theme: 'material', scheme: 'dark' },
|
||||
{ theme: 'material', scheme: 'light' },
|
||||
{ theme: 'cupertino', scheme: 'dark' },
|
||||
{ theme: 'cupertino', scheme: 'light' }
|
||||
]
|
||||
|
||||
describe('appearance', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.className = ''
|
||||
document.documentElement.style.colorScheme = ''
|
||||
document.head.querySelector('meta[name="theme-color"]')?.remove()
|
||||
})
|
||||
|
||||
it.each(COMBOS)('applies $theme · $scheme classes to document', ({ theme, scheme }) => {
|
||||
applyAppearanceToDocument(theme, scheme)
|
||||
|
||||
const root = document.documentElement
|
||||
expect(root.classList.contains(`theme-${theme}`)).toBe(true)
|
||||
expect(root.classList.contains(`scheme-${scheme}`)).toBe(true)
|
||||
expect(root.style.colorScheme).toBe(scheme)
|
||||
})
|
||||
|
||||
it('replaces previous theme classes when switching appearance', () => {
|
||||
applyAppearanceToDocument('ocean', 'dark')
|
||||
applyAppearanceToDocument('material', 'light')
|
||||
|
||||
const root = document.documentElement
|
||||
expect(root.classList.contains('theme-material')).toBe(true)
|
||||
expect(root.classList.contains('theme-ocean')).toBe(false)
|
||||
expect(root.classList.contains('scheme-light')).toBe(true)
|
||||
expect(root.classList.contains('scheme-dark')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves stored light scheme even when system prefers dark', () => {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })
|
||||
)
|
||||
localStorage.setItem('active_userid', USER_ID)
|
||||
setColorSchemePreference(USER_ID, 'light')
|
||||
|
||||
expect(resolveColorScheme()).toBe('light')
|
||||
applyAppearanceToDocument('material', resolveColorScheme())
|
||||
expect(document.documentElement.classList.contains('scheme-light')).toBe(true)
|
||||
})
|
||||
|
||||
it('auto theme picks material on Android user agent', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36'
|
||||
})
|
||||
expect(resolveAppTheme()).toBe('material')
|
||||
})
|
||||
})
|
||||
@@ -31,6 +31,18 @@ export function resolveAppTheme(): AppTheme {
|
||||
return 'ocean'
|
||||
}
|
||||
|
||||
function updateThemeColorMeta(root: HTMLElement): void {
|
||||
const color = getComputedStyle(root).getPropertyValue('--app-theme-color').trim()
|
||||
if (!color) return
|
||||
let meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta')
|
||||
meta.setAttribute('name', 'theme-color')
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
meta.setAttribute('content', color)
|
||||
}
|
||||
|
||||
export function applyAppearanceToDocument(
|
||||
theme: AppTheme = resolveAppTheme(),
|
||||
scheme: ResolvedColorScheme = resolveColorScheme()
|
||||
@@ -39,6 +51,7 @@ export function applyAppearanceToDocument(
|
||||
root.classList.remove(...THEME_CLASSES, ...SCHEME_CLASSES)
|
||||
root.classList.add(`theme-${theme}`, `scheme-${scheme}`)
|
||||
root.style.colorScheme = scheme
|
||||
updateThemeColorMeta(root)
|
||||
}
|
||||
|
||||
export function subscribeToSystemColorScheme(onChange: () => void): () => void {
|
||||
|
||||
@@ -88,6 +88,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
'Latitude', 'Longitude', 'Remarks',
|
||||
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
||||
'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)',
|
||||
'Greywater Level (L)',
|
||||
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
|
||||
];
|
||||
|
||||
@@ -123,6 +124,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const fuelR = entry.fuel?.refilled ?? '';
|
||||
const fuelE = entry.fuel?.evening ?? '';
|
||||
const fuelCons = entry.fuel?.consumption ?? '';
|
||||
const greywaterLevel = entry.greywater?.level ?? '';
|
||||
|
||||
const eventsList = entry.events || [];
|
||||
if (eventsList.length === 0) {
|
||||
@@ -137,6 +139,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
'', '', '',
|
||||
fwM, fwR, fwE, fwCons,
|
||||
fuelM, fuelR, fuelE, fuelCons,
|
||||
greywaterLevel,
|
||||
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
||||
].map(escapeCsvValue));
|
||||
} else {
|
||||
@@ -153,6 +156,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
||||
fwM, fwR, fwE, fwCons,
|
||||
fuelM, fuelR, fuelE, fuelCons,
|
||||
greywaterLevel,
|
||||
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
||||
].map(escapeCsvValue));
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
return { logbookId: existingId, title, firstEntryId }
|
||||
}
|
||||
clearDemoLogbookRefs(userId, existingId)
|
||||
}
|
||||
|
||||
if (!shouldSeed) return null
|
||||
@@ -152,3 +153,66 @@ export function getStoredDemoFirstEntryId(): string | null {
|
||||
if (!userId) return null
|
||||
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
||||
}
|
||||
|
||||
/** Remove persisted demo logbook pointers when the logbook no longer exists. */
|
||||
export function clearDemoLogbookRefs(userId: string, logbookId?: string): void {
|
||||
const storedId = localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||
if (logbookId && storedId && storedId !== logbookId) return
|
||||
localStorage.removeItem(getDemoLogbookStorageKey(userId))
|
||||
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
|
||||
}
|
||||
|
||||
export async function entryExistsInLogbook(logbookId: string, entryId: string): Promise<boolean> {
|
||||
const entry = await db.entries.get(entryId)
|
||||
return entry?.logbookId === logbookId
|
||||
}
|
||||
|
||||
export interface TourLogbookContext {
|
||||
logbookId: string
|
||||
title: string
|
||||
firstEntryId: string | null
|
||||
}
|
||||
|
||||
/** Pick a logbook + first entry for the onboarding tour (handles deleted demo data). */
|
||||
export async function resolveTourLogbookContext(
|
||||
preferLogbookId?: string | null
|
||||
): Promise<TourLogbookContext | null> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || !getActiveMasterKey()) return null
|
||||
|
||||
const demoId = localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||
if (demoId && !(await db.logbooks.get(demoId))) {
|
||||
clearDemoLogbookRefs(userId, demoId)
|
||||
}
|
||||
|
||||
const { fetchLogbooks } = await import('./logbook.js')
|
||||
const books = await fetchLogbooks()
|
||||
if (books.length === 0) return null
|
||||
|
||||
const activeId = localStorage.getItem('active_logbook_id')
|
||||
const pick =
|
||||
(preferLogbookId ? books.find((b) => b.id === preferLogbookId) : undefined) ??
|
||||
(activeId ? books.find((b) => b.id === activeId) : undefined) ??
|
||||
(demoId ? books.find((b) => b.id === demoId) : undefined) ??
|
||||
books[0]
|
||||
|
||||
const firstEntryId = await resolveTourFirstEntryId(pick.id, userId)
|
||||
return { logbookId: pick.id, title: pick.title, firstEntryId }
|
||||
}
|
||||
|
||||
async function resolveTourFirstEntryId(logbookId: string, userId: string): Promise<string | null> {
|
||||
const stored = localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
||||
if (stored && (await entryExistsInLogbook(logbookId, stored))) {
|
||||
return stored
|
||||
}
|
||||
|
||||
if (stored) {
|
||||
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
|
||||
}
|
||||
|
||||
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||
if (localEntries.length === 0) return null
|
||||
|
||||
localEntries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
return localEntries[0]?.payloadId ?? null
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface DemoDaySpec {
|
||||
filename: string
|
||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
greywaterLevel?: number
|
||||
motorHours?: number
|
||||
events: Array<Record<string, string>>
|
||||
}
|
||||
@@ -69,6 +70,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
||||
filename: 'kiel-laboe.gpx',
|
||||
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
|
||||
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
|
||||
greywaterLevel: 25,
|
||||
events: [
|
||||
{
|
||||
time: '10:15',
|
||||
@@ -101,6 +103,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
||||
filename: 'laboe-damp.gpx',
|
||||
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
|
||||
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
|
||||
greywaterLevel: 38,
|
||||
motorHours: 1.5,
|
||||
events: [
|
||||
{
|
||||
@@ -134,6 +137,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
||||
filename: 'damp-schleimuende.gpx',
|
||||
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
|
||||
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
|
||||
greywaterLevel: 52,
|
||||
events: [
|
||||
{
|
||||
time: '08:30',
|
||||
@@ -176,7 +180,10 @@ export function buildDemoYachtData(): Record<string, unknown> {
|
||||
atis: '',
|
||||
mmsi: '',
|
||||
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
|
||||
photo: null
|
||||
photo: null,
|
||||
freshwaterCapacityL: 200,
|
||||
fuelCapacityL: 100,
|
||||
greywaterCapacityL: 80
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +251,10 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
events: day.events
|
||||
}
|
||||
|
||||
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
|
||||
entryPayload.greywater = { level: day.greywaterLevel }
|
||||
}
|
||||
|
||||
if (stats) {
|
||||
entryPayload.trackDistanceNm = stats.distanceNm
|
||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||
@@ -303,6 +314,10 @@ export function buildDemoEntryPayloads(): Array<{
|
||||
events: day.events
|
||||
}
|
||||
|
||||
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
|
||||
entryPayload.greywater = { level: day.greywaterLevel }
|
||||
}
|
||||
|
||||
if (stats) {
|
||||
entryPayload.trackDistanceNm = stats.distanceNm
|
||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||
|
||||
@@ -4,6 +4,7 @@ import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto
|
||||
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
import { apiFetch } from './api.js'
|
||||
import { clearDemoLogbookRefs, getDemoLogbookStorageKey } from './demoLogbook.js'
|
||||
|
||||
const API_BASE = '/api/logbooks'
|
||||
|
||||
@@ -320,6 +321,9 @@ export async function deleteLogbook(id: string): Promise<void> {
|
||||
|
||||
// Perform local cascading cleanup
|
||||
await deleteLocalLogbookCache(id)
|
||||
if (userId && id === localStorage.getItem(getDemoLogbookStorageKey(userId))) {
|
||||
clearDemoLogbookRefs(userId, id)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
||||
}
|
||||
|
||||
|
||||
@@ -197,13 +197,15 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.text('VERBRAUCHSWERTE / CONSUMPTON STATS', 10, footerY + 3);
|
||||
|
||||
let fwY = footerY + 5;
|
||||
doc.rect(10, fwY, 110, rowHeight * 3, 'S');
|
||||
const tankRows = 4;
|
||||
doc.rect(10, fwY, 110, rowHeight * tankRows, 'S');
|
||||
doc.line(10, fwY + rowHeight, 120, fwY + rowHeight);
|
||||
doc.line(10, fwY + rowHeight * 2, 120, fwY + rowHeight * 2);
|
||||
doc.line(40, fwY, 40, fwY + rowHeight * 3);
|
||||
doc.line(60, fwY, 60, fwY + rowHeight * 3);
|
||||
doc.line(80, fwY, 80, fwY + rowHeight * 3);
|
||||
doc.line(100, fwY, 100, fwY + rowHeight * 3);
|
||||
doc.line(10, fwY + rowHeight * 3, 120, fwY + rowHeight * 3);
|
||||
doc.line(40, fwY, 40, fwY + rowHeight * tankRows);
|
||||
doc.line(60, fwY, 60, fwY + rowHeight * tankRows);
|
||||
doc.line(80, fwY, 80, fwY + rowHeight * tankRows);
|
||||
doc.line(100, fwY, 100, fwY + rowHeight * tankRows);
|
||||
|
||||
doc.setFont('Helvetica', 'bold');
|
||||
doc.setFontSize(7.5);
|
||||
@@ -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?.consumption ?? '0'), 101, fwY + rowHeight * 2 + 4.2);
|
||||
|
||||
doc.text('Grauwasser', 11, fwY + rowHeight * 3 + 4.2);
|
||||
doc.text('—', 41, fwY + rowHeight * 3 + 4.2);
|
||||
doc.text('—', 61, fwY + rowHeight * 3 + 4.2);
|
||||
doc.text(String(entry.greywater?.level ?? '0'), 81, fwY + rowHeight * 3 + 4.2);
|
||||
doc.text('—', 101, fwY + rowHeight * 3 + 4.2);
|
||||
|
||||
// Signatures Box
|
||||
let sigX = 130;
|
||||
let sigY = footerY + 5;
|
||||
|
||||
@@ -43,6 +43,18 @@ async function fetchVapidPublicKey(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** True when crew-change push is enabled and notification permission is granted. */
|
||||
export async function isCollaboratorPushActive(): Promise<boolean> {
|
||||
if (!isPushSupported()) return false
|
||||
if (getNotificationPermission() !== 'granted') return false
|
||||
try {
|
||||
const prefs = await fetchPushPrefs()
|
||||
return prefs.collaboratorChangesEnabled
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
|
||||
if (!localStorage.getItem('active_userid')) {
|
||||
return { collaboratorChangesEnabled: false }
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
/* Fallback before JS hydrates (ocean · dark) */
|
||||
html {
|
||||
color-scheme: dark;
|
||||
--app-theme-color: #0b0c10;
|
||||
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
|
||||
--app-text: #f1f5f9;
|
||||
--app-text-heading: #f8fafc;
|
||||
@@ -61,6 +62,7 @@ html {
|
||||
/* ===== OCEAN · DARK (default) ===== */
|
||||
html.scheme-dark.theme-ocean {
|
||||
color-scheme: dark;
|
||||
--app-theme-color: #0b0c10;
|
||||
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
|
||||
--app-text: #f1f5f9;
|
||||
--app-text-heading: #f8fafc;
|
||||
@@ -116,6 +118,7 @@ html.scheme-dark.theme-ocean {
|
||||
/* ===== OCEAN · LIGHT ===== */
|
||||
html.scheme-light.theme-ocean {
|
||||
color-scheme: light;
|
||||
--app-theme-color: #e2e8f0;
|
||||
--app-body-bg: linear-gradient(165deg, #dbeafe 0%, #f8fafc 42%, #e2e8f0 100%);
|
||||
--app-text: #1e293b;
|
||||
--app-text-heading: #0f172a;
|
||||
@@ -171,6 +174,7 @@ html.scheme-light.theme-ocean {
|
||||
/* ===== MATERIAL · DARK ===== */
|
||||
html.scheme-dark.theme-material {
|
||||
color-scheme: dark;
|
||||
--app-theme-color: #121212;
|
||||
--app-body-bg: #121212;
|
||||
--app-text: #f1f5f9;
|
||||
--app-text-heading: #f8fafc;
|
||||
@@ -226,6 +230,7 @@ html.scheme-dark.theme-material {
|
||||
/* ===== MATERIAL · LIGHT ===== */
|
||||
html.scheme-light.theme-material {
|
||||
color-scheme: light;
|
||||
--app-theme-color: #fafafa;
|
||||
--app-body-bg: #fafafa;
|
||||
--app-text: #212121;
|
||||
--app-text-heading: #111827;
|
||||
@@ -281,6 +286,7 @@ html.scheme-light.theme-material {
|
||||
/* ===== CUPERTINO · DARK ===== */
|
||||
html.scheme-dark.theme-cupertino {
|
||||
color-scheme: dark;
|
||||
--app-theme-color: #000000;
|
||||
--app-body-bg: #000000;
|
||||
--app-text: #ffffff;
|
||||
--app-text-heading: #ffffff;
|
||||
@@ -336,6 +342,7 @@ html.scheme-dark.theme-cupertino {
|
||||
/* ===== CUPERTINO · LIGHT ===== */
|
||||
html.scheme-light.theme-cupertino {
|
||||
color-scheme: light;
|
||||
--app-theme-color: #f2f2f7;
|
||||
--app-body-bg: #f2f2f7;
|
||||
--app-text: #1c1c1e;
|
||||
--app-text-heading: #000000;
|
||||
@@ -396,3 +403,12 @@ html.scheme-light.theme-cupertino {
|
||||
html.scheme-light #root {
|
||||
border-inline-color: var(--app-border-subtle);
|
||||
}
|
||||
|
||||
/* Bridge legacy index.css tokens to appearance (avoids system color-scheme drift) */
|
||||
html.scheme-light,
|
||||
html.scheme-dark {
|
||||
--text: var(--app-text);
|
||||
--text-h: var(--app-text-heading);
|
||||
--code-bg: var(--app-icon-btn-bg);
|
||||
--border: var(--app-border-subtle);
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time')
|
||||
|
||||
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */
|
||||
export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
|
||||
return LOG_EVENT_CONTENT_FIELDS.every((key) => !event[key]?.trim())
|
||||
}
|
||||
|
||||
/** Whether the event form holds unsaved changes worth merging on page save. */
|
||||
export function hasUnsavedEventDraft(
|
||||
draft: LogEventPayload,
|
||||
editingEventIndex: number | null,
|
||||
events: LogEventPayload[]
|
||||
): boolean {
|
||||
if (!isValidTimeHHMM(draft.time)) return false
|
||||
if (editingEventIndex !== null) {
|
||||
const original = events[editingEventIndex]
|
||||
return original ? !logEventsEqual(draft, original) : false
|
||||
}
|
||||
return !isLogEventDraftEmpty(draft)
|
||||
}
|
||||
|
||||
/** Chronological order: earliest time first (HH:MM). */
|
||||
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
|
||||
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
||||
@@ -123,6 +144,7 @@ export interface LogEntryPayloadInput {
|
||||
destination: string
|
||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
greywater?: { level: number }
|
||||
trackDistanceNm?: number
|
||||
trackSpeedMaxKn?: number
|
||||
trackSpeedAvgKn?: number
|
||||
@@ -148,5 +170,12 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
|
||||
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
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
|
||||
export interface LogEntryTankSource {
|
||||
freshwater?: Partial<TankLevels>
|
||||
fuel?: Partial<TankLevels>
|
||||
greywater?: { level?: number }
|
||||
destination?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -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>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 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>PDF- & CSV-Export</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() {
|
||||
if git diff-index --quiet HEAD -- && [ -z "$(git status --porcelain)" ]; then
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user