Extend live journal with weather, tanks, undo, and event series stats.

Adds weather and course quick actions, diesel/water refills, five-second undo, foreground auto-position every three hours, and chronological pressure/wind/motor series in the stats tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 21:11:52 +02:00
parent 039e4e2736
commit 5b47415d55
13 changed files with 966 additions and 92 deletions
+90
View File
@@ -3340,6 +3340,96 @@ html.theme-cupertino .events-scroll-container {
font-size: 13px;
padding: 10px 12px;
}
.live-log-weather-group {
flex: 1 1 100%;
}
}
.live-log-weather-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.live-log-weather-toggle {
justify-content: space-between;
}
.live-log-weather-toggle.is-expanded {
border-color: rgba(59, 130, 246, 0.35);
}
.live-log-weather-submenu {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 8px;
}
.live-log-subaction-btn {
display: flex;
align-items: center;
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--app-border-muted);
background: rgba(0, 0, 0, 0.15);
color: var(--app-text-muted);
font-size: 13px;
cursor: pointer;
}
.live-log-subaction-btn:hover:not(:disabled) {
color: var(--app-text);
border-color: rgba(59, 130, 246, 0.3);
}
.live-log-undo-bar {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
z-index: 900;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: 12px;
background: var(--app-surface-alt);
border: 1px solid var(--app-border-muted);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
font-size: 14px;
}
.stats-event-series-block + .stats-event-series-block {
margin-top: 16px;
}
.stats-event-series-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
max-height: 180px;
overflow-y: auto;
}
.stats-event-series-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 0;
border-bottom: 1px solid var(--app-border-muted);
font-size: 13px;
}
.stats-event-series-when {
color: var(--app-text-muted);
white-space: nowrap;
}
.stats-event-series-value {
text-align: right;
}
.grid-span-2 {
+321 -65
View File
@@ -2,12 +2,19 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Anchor,
ChevronDown,
ChevronLeft,
ChevronUp,
CloudSun,
Compass,
Droplets,
FileText,
Fuel,
MapPin,
MessageSquare,
Radio,
Sailboat,
Undo2,
Zap
} from 'lucide-react'
import { db } from '../services/db.js'
@@ -17,15 +24,22 @@ import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import {
appendQuickEvent,
appendTankRefill,
findOrCreateTodayEntry,
loadEntry
loadEntry,
removeLastEvent
} from '../services/quickEventLog.js'
import { formatEventSummary } from '../utils/formatEventSummary.js'
import {
getLastAutoPositionMs,
isMotorRunningFromEvents,
LIVE_EVENT_CODES,
liveCommentRemark,
liveSailsRemark
liveFuelRemark,
livePrecipRemark,
liveSailsRemark,
liveTempRemark,
liveWaterRemark
} from '../utils/liveEventCodes.js'
import { getCurrentPosition } from '../utils/geolocation.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
@@ -37,12 +51,34 @@ interface LiveLogViewProps {
onSwitchToList: () => void
}
type LiveModal = 'none' | 'sails' | 'comment'
type LiveModal =
| 'none'
| 'sails'
| 'comment'
| 'wind'
| 'pressure'
| 'temp'
| 'precip'
| 'sea_state'
| 'course'
| 'fuel'
| 'water'
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
const AUTO_POSITION_CHECK_MS = 60_000
const UNDO_TIMEOUT_MS = 5000
function hapticPulse() {
navigator.vibrate?.(40)
}
function lastCourseFromEvents(events: LogEventPayload[]): string {
for (let i = events.length - 1; i >= 0; i--) {
if (events[i].mgk.trim()) return events[i].mgk
}
return ''
}
export default function LiveLogView({
logbookId,
onOpenEditor,
@@ -60,10 +96,16 @@ export default function LiveLogView({
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [modal, setModal] = useState<LiveModal>('none')
const [weatherExpanded, setWeatherExpanded] = useState(false)
const [commentText, setCommentText] = useState('')
const [valueInput, setValueInput] = useState('')
const [valueInputSecondary, setValueInputSecondary] = useState('')
const [selectedSails, setSelectedSails] = useState<string[]>([])
const [undoVisible, setUndoVisible] = useState(false)
const streamEndRef = useRef<HTMLDivElement | null>(null)
const undoTimerRef = useRef<number | null>(null)
const autoPositionBusyRef = useRef(false)
const defaultSails = i18n.language === 'de'
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
@@ -81,6 +123,15 @@ export default function LiveLogView({
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
}, [logbookId])
const showUndo = useCallback(() => {
setUndoVisible(true)
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
undoTimerRef.current = window.setTimeout(() => {
setUndoVisible(false)
undoTimerRef.current = null
}, UNDO_TIMEOUT_MS)
}, [])
useEffect(() => {
let cancelled = false
@@ -122,9 +173,45 @@ export default function LiveLogView({
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [events.length])
useEffect(() => {
return () => {
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
}
}, [])
useEffect(() => {
if (!entryId || loading) return
const maybeAutoPosition = async () => {
if (document.visibilityState !== 'visible' || autoPositionBusyRef.current || busy) return
const lastMs = getLastAutoPositionMs(events, date)
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
autoPositionBusyRef.current = true
try {
const coords = await getCurrentPosition()
await appendQuickEvent(logbookId, entryId, {
gpsLat: coords.lat,
gpsLng: coords.lng,
remarks: LIVE_EVENT_CODES.AUTO_POSITION
})
await refreshEntry(entryId)
} catch {
// Silent — auto-position is best-effort
} finally {
autoPositionBusyRef.current = false
}
}
const interval = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
return () => window.clearInterval(interval)
}, [entryId, loading, events, date, logbookId, refreshEntry, busy])
const runQuickAction = async (
action: () => Promise<void>,
trackEvent?: string
trackEvent?: string,
withUndo = true
) => {
if (!entryId || busy) return
setBusy(true)
@@ -132,6 +219,7 @@ export default function LiveLogView({
try {
await action()
await refreshEntry(entryId)
if (withUndo) showUndo()
if (trackEvent) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED, { context: trackEvent })
} catch (err: unknown) {
console.error('Live log action failed:', err)
@@ -141,6 +229,12 @@ export default function LiveLogView({
}
}
const openValueModal = (type: LiveModal, primary = '', secondary = '') => {
setValueInput(primary)
setValueInputSecondary(secondary)
setModal(type)
}
const handleMotorToggle = () => {
hapticPulse()
void runQuickAction(async () => {
@@ -156,18 +250,14 @@ export default function LiveLogView({
const handleCastOff = () => {
void runQuickAction(async () => {
if (!entryId) return
await appendQuickEvent(logbookId, entryId, {
remarks: LIVE_EVENT_CODES.CAST_OFF
})
await appendQuickEvent(logbookId, entryId, { remarks: LIVE_EVENT_CODES.CAST_OFF })
}, 'live_cast_off')
}
const handleMoor = () => {
void runQuickAction(async () => {
if (!entryId) return
await appendQuickEvent(logbookId, entryId, {
remarks: LIVE_EVENT_CODES.MOOR
})
await appendQuickEvent(logbookId, entryId, { remarks: LIVE_EVENT_CODES.MOOR })
}, 'live_moor')
}
@@ -187,12 +277,16 @@ export default function LiveLogView({
}, 'live_fix')
}
const toggleSailSelection = (sail: string) => {
setSelectedSails((prev) =>
prev.some((s) => s.toLowerCase() === sail.toLowerCase())
? prev.filter((s) => s.toLowerCase() !== sail.toLowerCase())
: [...prev, sail]
)
const handleUndo = () => {
if (!entryId || busy) return
setUndoVisible(false)
if (undoTimerRef.current) {
window.clearTimeout(undoTimerRef.current)
undoTimerRef.current = null
}
void runQuickAction(async () => {
await removeLastEvent(logbookId, entryId)
}, 'live_undo', false)
}
const confirmSails = () => {
@@ -222,12 +316,108 @@ export default function LiveLogView({
setCommentText('')
void runQuickAction(async () => {
if (!entryId) return
await appendQuickEvent(logbookId, entryId, {
remarks: liveCommentRemark(text)
})
await appendQuickEvent(logbookId, entryId, { remarks: liveCommentRemark(text) })
}, 'live_comment')
}
const confirmValueModal = () => {
if (!entryId) return
const primary = valueInput.trim()
const secondary = valueInputSecondary.trim()
switch (modal) {
case 'wind':
if (!primary && !secondary) return
setModal('none')
void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, {
windDirection: primary,
windStrength: secondary,
remarks: LIVE_EVENT_CODES.WIND
})
}, 'live_wind')
break
case 'pressure':
if (!primary) return
setModal('none')
void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, {
windPressure: primary,
remarks: LIVE_EVENT_CODES.PRESSURE
})
}, 'live_pressure')
break
case 'temp':
if (!primary) return
setModal('none')
void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, { remarks: liveTempRemark(primary) })
}, 'live_temp')
break
case 'precip':
if (!primary) return
setModal('none')
void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, { remarks: livePrecipRemark(primary) })
}, 'live_precip')
break
case 'sea_state':
if (!primary) return
setModal('none')
void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, {
seaState: primary,
remarks: LIVE_EVENT_CODES.SEA_STATE
})
}, 'live_sea_state')
break
case 'course': {
const course = primary || lastCourseFromEvents(events)
if (!course) return
setModal('none')
void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, {
mgk: course,
remarks: LIVE_EVENT_CODES.COURSE
})
}, 'live_course')
break
}
case 'fuel': {
const liters = parseFloat(primary)
if (!Number.isFinite(liters) || liters <= 0) return
setModal('none')
void runQuickAction(async () => {
await appendTankRefill(logbookId, entryId, 'fuel', liters, {
remarks: liveFuelRemark(String(liters))
})
}, 'live_fuel')
break
}
case 'water': {
const liters = parseFloat(primary)
if (!Number.isFinite(liters) || liters <= 0) return
setModal('none')
void runQuickAction(async () => {
await appendTankRefill(logbookId, entryId, 'freshwater', liters, {
remarks: liveWaterRemark(String(liters))
})
}, 'live_water')
break
}
default:
break
}
}
const toggleSailSelection = (sail: string) => {
setSelectedSails((prev) =>
prev.some((s) => s.toLowerCase() === sail.toLowerCase())
? prev.filter((s) => s.toLowerCase() !== sail.toLowerCase())
: [...prev, sail]
)
}
if (loading) {
return (
<div className="tab-placeholder">
@@ -252,22 +442,12 @@ export default function LiveLogView({
</div>
</div>
<div className="section-toolbar">
<button
type="button"
className="btn secondary"
onClick={onSwitchToList}
style={{ width: 'auto', padding: '8px 16px' }}
>
<button type="button" className="btn secondary" onClick={onSwitchToList} style={{ width: 'auto', padding: '8px 16px' }}>
<ChevronLeft size={16} />
<span className="hide-mobile">{t('logs.view_list')}</span>
</button>
{entryId && (
<button
type="button"
className="btn secondary"
onClick={() => onOpenEditor(entryId)}
style={{ width: 'auto', padding: '8px 16px' }}
>
<button type="button" className="btn secondary" onClick={() => onOpenEditor(entryId)} style={{ width: 'auto', padding: '8px 16px' }}>
<FileText size={16} />
<span className="hide-mobile">{t('logs.live_open_editor')}</span>
</button>
@@ -279,12 +459,7 @@ export default function LiveLogView({
<div className="live-log-layout">
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}>
<button
type="button"
className={`live-log-action-btn ${motorRunning ? 'is-active' : ''}`}
onClick={handleMotorToggle}
disabled={busy}
>
<button type="button" className={`live-log-action-btn ${motorRunning ? 'is-active' : ''}`} onClick={handleMotorToggle} disabled={busy}>
<Zap size={18} />
{motorRunning ? t('logs.live_motor_stop') : t('logs.live_motor_start')}
</button>
@@ -296,25 +471,61 @@ export default function LiveLogView({
<Anchor size={18} style={{ transform: 'scaleX(-1)' }} />
{t('logs.live_moor')}
</button>
<button
type="button"
className="live-log-action-btn"
onClick={() => { setSelectedSails([]); setModal('sails') }}
disabled={busy}
>
<button type="button" className="live-log-action-btn" onClick={() => { setSelectedSails([]); setModal('sails') }} disabled={busy}>
<Sailboat size={18} />
{t('logs.live_sails_btn')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('course', lastCourseFromEvents(events))} disabled={busy}>
<Compass size={18} />
{t('logs.live_course_btn')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('fuel')} disabled={busy}>
<Fuel size={18} />
{t('logs.live_fuel_btn')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('water')} disabled={busy}>
<Droplets size={18} />
{t('logs.live_water_btn')}
</button>
<div className="live-log-weather-group">
<button
type="button"
className={`live-log-action-btn live-log-weather-toggle ${weatherExpanded ? 'is-expanded' : ''}`}
onClick={() => setWeatherExpanded((prev) => !prev)}
disabled={busy}
aria-expanded={weatherExpanded}
>
<CloudSun size={18} />
{t('logs.live_weather_btn')}
{weatherExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{weatherExpanded && (
<div className="live-log-weather-submenu">
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('wind')} disabled={busy}>
{t('logs.live_wind_btn')}
</button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('temp')} disabled={busy}>
{t('logs.live_temp_btn')}
</button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('pressure')} disabled={busy}>
{t('logs.live_pressure_btn')}
</button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('precip')} disabled={busy}>
{t('logs.live_precip_btn')}
</button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('sea_state')} disabled={busy}>
{t('logs.live_sea_state_btn')}
</button>
</div>
)}
</div>
<button type="button" className="live-log-action-btn" onClick={handleFix} disabled={busy}>
<MapPin size={18} />
{t('logs.live_fix')}
</button>
<button
type="button"
className="live-log-action-btn"
onClick={() => { setCommentText(''); setModal('comment') }}
disabled={busy}
>
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
<MessageSquare size={18} />
{t('logs.live_comment_btn')}
</button>
@@ -338,6 +549,16 @@ export default function LiveLogView({
</section>
</div>
{undoVisible && events.length > 0 && (
<div className="live-log-undo-bar" role="status">
<span>{t('logs.live_undo_hint')}</span>
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
<Undo2 size={16} />
{t('logs.live_undo_btn')}
</button>
</div>
)}
{modal === 'sails' && (
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div className="live-log-modal glass" onClick={(e) => e.stopPropagation()}>
@@ -355,12 +576,8 @@ export default function LiveLogView({
))}
</div>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>
{t('logs.confirm_no')}
</button>
<button type="button" className="btn primary" onClick={confirmSails} disabled={selectedSails.length === 0}>
{t('logs.live_sails_confirm')}
</button>
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
<button type="button" className="btn primary" onClick={confirmSails} disabled={selectedSails.length === 0}>{t('logs.live_sails_confirm')}</button>
</div>
</div>
</div>
@@ -370,22 +587,61 @@ export default function LiveLogView({
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div className="live-log-modal glass" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_comment_btn')}</h3>
<input type="text" className="input-text" value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder={t('logs.live_comment_placeholder')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }} />
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
<button type="button" className="btn primary" onClick={confirmComment} disabled={!commentText.trim()}>{t('logs.live_comment_confirm')}</button>
</div>
</div>
</div>
)}
{modal === 'wind' && (
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div className="live-log-modal glass" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_wind_btn')}</h3>
<input type="text" className="input-text mb-2" value={valueInput} onChange={(e) => setValueInput(e.target.value)} placeholder={t('logs.event_wind_direction')} autoFocus />
<input type="text" className="input-text" value={valueInputSecondary} onChange={(e) => setValueInputSecondary(e.target.value)} placeholder={t('logs.event_wind_strength')} />
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div>
</div>
</div>
)}
{['pressure', 'temp', 'precip', 'sea_state', 'course', 'fuel', 'water'].includes(modal) && (
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div className="live-log-modal glass" onClick={(e) => e.stopPropagation()}>
<h3>
{modal === 'pressure' && t('logs.live_pressure_btn')}
{modal === 'temp' && t('logs.live_temp_btn')}
{modal === 'precip' && t('logs.live_precip_btn')}
{modal === 'sea_state' && t('logs.live_sea_state_btn')}
{modal === 'course' && t('logs.live_course_btn')}
{modal === 'fuel' && t('logs.live_fuel_btn')}
{modal === 'water' && t('logs.live_water_btn')}
</h3>
<input
type="text"
className="input-text"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder={t('logs.live_comment_placeholder')}
value={valueInput}
onChange={(e) => setValueInput(e.target.value)}
placeholder={
modal === 'pressure' ? t('logs.live_pressure_placeholder')
: modal === 'temp' ? t('logs.live_temp_placeholder')
: modal === 'precip' ? t('logs.live_precip_placeholder')
: modal === 'sea_state' ? t('logs.live_sea_state_placeholder')
: modal === 'course' ? t('logs.live_course_placeholder')
: modal === 'fuel' ? t('logs.live_fuel_placeholder')
: t('logs.live_water_placeholder')
}
autoFocus
onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }}
onKeyDown={(e) => { if (e.key === 'Enter') confirmValueModal() }}
/>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>
{t('logs.confirm_no')}
</button>
<button type="button" className="btn primary" onClick={confirmComment} disabled={!commentText.trim()}>
{t('logs.live_comment_confirm')}
</button>
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div>
</div>
</div>
+69 -4
View File
@@ -14,6 +14,11 @@ import {
} from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
import {
loadLogbookEventSeries,
type EventSeriesPoint,
type EventSeriesSummary
} from '../services/eventSeriesAggregation.js'
interface StatsDashboardProps {
logbookId: string
@@ -217,7 +222,62 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
)
}
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
function EventSeriesList({ title, points, emptyLabel }: { title: string; points: EventSeriesPoint[]; emptyLabel: string }) {
if (points.length === 0) {
return (
<div className="stats-event-series-block">
<h4 className="stats-section-subtitle">{title}</h4>
<p className="stats-section-sub">{emptyLabel}</p>
</div>
)
}
return (
<div className="stats-event-series-block">
<h4 className="stats-section-subtitle">{title}</h4>
<ul className="stats-event-series-list">
{points.map((point, idx) => (
<li key={`${point.entryId}-${point.time}-${idx}`} className="stats-event-series-item">
<span className="stats-event-series-when">
{new Date(point.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}
{' · '}
{point.time}
</span>
<span className="stats-event-series-value">{point.summary}</span>
</li>
))}
</ul>
</div>
)
}
function EventSeriesPanel({ series }: { series: EventSeriesSummary }) {
const { t } = useTranslation()
const motorPoints = series.motor.map((point) => ({
...point,
summary: point.summary === 'start'
? t('logs.live_motor_start')
: t('logs.live_motor_stop')
}))
return (
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.event_series_title')}</h3>
<p className="stats-section-sub">{t('stats.event_series_hint')}</p>
<EventSeriesList title={t('stats.event_series_pressure')} points={series.pressure} emptyLabel={t('stats.event_series_empty')} />
<EventSeriesList title={t('stats.event_series_wind')} points={series.wind} emptyLabel={t('stats.event_series_empty')} />
<EventSeriesList title={t('stats.event_series_motor')} points={motorPoints} emptyLabel={t('stats.event_series_empty')} />
</div>
)
}
function LogbookScopeView({
summary,
eventSeries
}: {
summary: LogbookStatsSummary
eventSeries: EventSeriesSummary | null
}) {
const { t } = useTranslation()
const { travelDays, routePorts, trackSegments, totals } = summary
@@ -313,6 +373,8 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
<PropulsionBreakdown totals={totals} />
</div>
{eventSeries && <EventSeriesPanel series={eventSeries} />}
</>
)
}
@@ -323,18 +385,21 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
const [eventSeries, setEventSeries] = useState<EventSeriesSummary | null>(null)
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [lb, acc] = await Promise.all([
const [lb, acc, series] = await Promise.all([
loadLogbookStats(logbookId, logbookTitle, true),
loadAccountStats(false)
loadAccountStats(false),
loadLogbookEventSeries(logbookId)
])
setLogbookStats(lb)
setAccountStats(acc)
setEventSeries(series)
} catch (err: unknown) {
console.error('Failed to load statistics:', err)
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
@@ -397,7 +462,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<p>{t('stats.loading')}</p>
</div>
) : scope === 'logbook' && logbookStats ? (
<LogbookScopeView summary={logbookStats} />
<LogbookScopeView summary={logbookStats} eventSeries={eventSeries} />
) : scope === 'account' && accountStats ? (
<>
<TotalsGrid totals={accountStats.totals} />
+34 -1
View File
@@ -224,6 +224,33 @@
"live_comment_confirm": "Indtast",
"live_gps_error": "GPS-position kunne ikke bestemmes.",
"live_event_generic": "Hændelse",
"live_weather_btn": "Vejr",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryk",
"live_precip_btn": "Nedbør",
"live_sea_state_btn": "Søgang",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vand",
"live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttryk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Søgang {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vand +{{liters}} L",
"live_auto_position": "Auto-position",
"live_undo_hint": "Indtastning gemt",
"live_undo_btn": "Fortryd",
"live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. let regn",
"live_sea_state_placeholder": "f.eks. 3",
"live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Optankede liter",
"live_water_placeholder": "Optankede liter",
"delete_entry": "Slet tag",
"delete_confirm": "Er du sikker på, at du vil slette denne rejsedag permanent?",
"carry_over_tanks_title": "Overføre data fra den foregående dag?",
@@ -740,7 +767,13 @@
"unit_l": "L",
"day_label": "Dag {{day}}",
"account_logbooks": "Et overblik over logbøger",
"col_logbook": "Logbog"
"col_logbook": "Logbog",
"event_series_title": "Hændelsesforløb",
"event_series_hint": "Kronologiske værdier fra hændelsesloggen.",
"event_series_pressure": "Lufttryk",
"event_series_wind": "Vind",
"event_series_motor": "Motor",
"event_series_empty": "Ingen indtastninger endnu."
},
"tour": {
"skip": "Spring turen over",
+34 -1
View File
@@ -224,6 +224,33 @@
"live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"live_event_generic": "Ereignis",
"live_weather_btn": "Wetter",
"live_wind_btn": "Wind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Luftdruck",
"live_precip_btn": "Niederschlag",
"live_sea_state_btn": "Seegang",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Wasser",
"live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Luftdruck {{value}} hPa",
"live_precip_entry": "Niederschlag {{value}}",
"live_sea_state_entry": "Seegang {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Wasser +{{liters}} L",
"live_auto_position": "Auto-Position",
"live_undo_hint": "Eintrag gespeichert",
"live_undo_btn": "Rückgängig",
"live_pressure_placeholder": "z. B. 1013",
"live_temp_placeholder": "z. B. 18",
"live_precip_placeholder": "z. B. leichter Regen",
"live_sea_state_placeholder": "z. B. 3",
"live_course_placeholder": "z. B. 245",
"live_fuel_placeholder": "Nachgefüllte Liter",
"live_water_placeholder": "Nachgefüllte Liter",
"delete_entry": "Tag löschen",
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
@@ -740,7 +767,13 @@
"unit_l": "L",
"day_label": "Tag {{day}}",
"account_logbooks": "Logbücher im Überblick",
"col_logbook": "Logbuch"
"col_logbook": "Logbuch",
"event_series_title": "Ereignis-Verläufe",
"event_series_hint": "Chronologische Werte aus dem Ereignisprotokoll.",
"event_series_pressure": "Luftdruck",
"event_series_wind": "Wind",
"event_series_motor": "Motor",
"event_series_empty": "Keine Einträge vorhanden."
},
"tour": {
"skip": "Tour überspringen",
+34 -1
View File
@@ -224,6 +224,33 @@
"live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.",
"live_event_generic": "Event",
"live_weather_btn": "Weather",
"live_wind_btn": "Wind",
"live_temp_btn": "Temp °C",
"live_pressure_btn": "Pressure",
"live_precip_btn": "Precipitation",
"live_sea_state_btn": "Sea state",
"live_course_btn": "Course",
"live_fuel_btn": "Fuel",
"live_water_btn": "Water",
"live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperature {{temp}} °C",
"live_pressure_entry": "Pressure {{value}} hPa",
"live_precip_entry": "Precipitation {{value}}",
"live_sea_state_entry": "Sea state {{value}}",
"live_course_entry": "Course {{course}}",
"live_fuel_entry": "Fuel +{{liters}} L",
"live_water_entry": "Water +{{liters}} L",
"live_auto_position": "Auto position",
"live_undo_hint": "Entry saved",
"live_undo_btn": "Undo",
"live_pressure_placeholder": "e.g. 1013",
"live_temp_placeholder": "e.g. 18",
"live_precip_placeholder": "e.g. light rain",
"live_sea_state_placeholder": "e.g. 3",
"live_course_placeholder": "e.g. 245",
"live_fuel_placeholder": "Liters refilled",
"live_water_placeholder": "Liters refilled",
"delete_entry": "Delete Day",
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
"carry_over_tanks_title": "Carry over from previous day?",
@@ -740,7 +767,13 @@
"unit_l": "L",
"day_label": "Day {{day}}",
"account_logbooks": "Logbooks overview",
"col_logbook": "Logbook"
"col_logbook": "Logbook",
"event_series_title": "Event series",
"event_series_hint": "Chronological values from the event log.",
"event_series_pressure": "Barometric pressure",
"event_series_wind": "Wind",
"event_series_motor": "Engine",
"event_series_empty": "No entries yet."
},
"tour": {
"skip": "Skip tour",
+34 -1
View File
@@ -224,6 +224,33 @@
"live_comment_confirm": "Loggfør",
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
"live_event_generic": "Hendelse",
"live_weather_btn": "Vær",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttrykk",
"live_precip_btn": "Nedbør",
"live_sea_state_btn": "Sjøgang",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vann",
"live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttrykk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Sjøgang {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vann +{{liters}} L",
"live_auto_position": "Auto-posisjon",
"live_undo_hint": "Oppføring lagret",
"live_undo_btn": "Angre",
"live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. lett regn",
"live_sea_state_placeholder": "f.eks. 3",
"live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Påfylte liter",
"live_water_placeholder": "Påfylte liter",
"delete_entry": "Slett tagg",
"delete_confirm": "Er du sikker på at du vil slette denne reisedagen permanent?",
"carry_over_tanks_title": "Overføre data fra dagen før?",
@@ -740,7 +767,13 @@
"unit_l": "L",
"day_label": "Dag {{day}}",
"account_logbooks": "Oversikt over loggbøker",
"col_logbook": "Loggbok"
"col_logbook": "Loggbok",
"event_series_title": "Hendelsesforløp",
"event_series_hint": "Kronologiske verdier fra hendelsesloggen.",
"event_series_pressure": "Lufttrykk",
"event_series_wind": "Vind",
"event_series_motor": "Motor",
"event_series_empty": "Ingen oppføringer ennå."
},
"tour": {
"skip": "Hopp over turen",
+34 -1
View File
@@ -224,6 +224,33 @@
"live_comment_confirm": "Logga",
"live_gps_error": "GPS-position kunde inte bestämmas.",
"live_event_generic": "Händelse",
"live_weather_btn": "Väder",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryck",
"live_precip_btn": "Nederbörd",
"live_sea_state_btn": "Sjögang",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vatten",
"live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttryck {{value}} hPa",
"live_precip_entry": "Nederbörd {{value}}",
"live_sea_state_entry": "Sjögang {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vatten +{{liters}} L",
"live_auto_position": "Auto-position",
"live_undo_hint": "Post sparad",
"live_undo_btn": "Ångra",
"live_pressure_placeholder": "t.ex. 1013",
"live_temp_placeholder": "t.ex. 18",
"live_precip_placeholder": "t.ex. lätt regn",
"live_sea_state_placeholder": "t.ex. 3",
"live_course_placeholder": "t.ex. 245",
"live_fuel_placeholder": "Påfyllda liter",
"live_water_placeholder": "Påfyllda liter",
"delete_entry": "Ta bort tagg",
"delete_confirm": "Är du säker på att du vill radera den här resedagen permanent?",
"carry_over_tanks_title": "Överföra data från föregående dag?",
@@ -740,7 +767,13 @@
"unit_l": "L",
"day_label": "Dag {{day}}__.",
"account_logbooks": "Loggböcker i en överblick",
"col_logbook": "Loggbok"
"col_logbook": "Loggbok",
"event_series_title": "Händelseförlopp",
"event_series_hint": "Kronologiska värden från händelseloggen.",
"event_series_pressure": "Lufttryck",
"event_series_wind": "Vind",
"event_series_motor": "Motor",
"event_series_empty": "Inga poster ännu."
},
"tour": {
"skip": "Hoppa över turen",
@@ -0,0 +1,106 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { LIVE_EVENT_CODES } from '../utils/liveEventCodes.js'
export interface EventSeriesPoint {
entryId: string
date: string
dayOfTravel: string
time: string
summary: string
}
export interface EventSeriesSummary {
pressure: EventSeriesPoint[]
wind: EventSeriesPoint[]
motor: EventSeriesPoint[]
}
function sortPoints(points: EventSeriesPoint[]): EventSeriesPoint[] {
return [...points].sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date)
if (dateCompare !== 0) return dateCompare
return a.time.localeCompare(b.time)
})
}
export async function loadLogbookEventSeries(logbookId: string): Promise<EventSeriesSummary> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<{
entryId: string
date: string
dayOfTravel: string
events: LogEventPayload[]
}> = []
for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (!decrypted) continue
decryptedEntries.push({
entryId: entry.payloadId,
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
events: (decrypted.events as LogEventPayload[]) || []
})
}
decryptedEntries.sort((a, b) =>
compareTravelDaysChronological(
{ date: a.date, dayOfTravel: a.dayOfTravel },
{ date: b.date, dayOfTravel: b.dayOfTravel }
)
)
const pressure: EventSeriesPoint[] = []
const wind: EventSeriesPoint[] = []
const motor: EventSeriesPoint[] = []
for (const entry of decryptedEntries) {
for (const event of entry.events) {
const base = {
entryId: entry.entryId,
date: entry.date,
dayOfTravel: entry.dayOfTravel,
time: event.time
}
if (event.windPressure.trim()) {
pressure.push({
...base,
summary: `${event.windPressure} hPa`
})
}
if (event.windDirection.trim() || event.windStrength.trim()) {
wind.push({
...base,
summary: [event.windDirection, event.windStrength].filter(Boolean).join(' ')
})
}
const code = event.remarks.trim()
if (
code === LIVE_EVENT_CODES.MOTOR_START ||
code === LIVE_EVENT_CODES.MOTOR_STOP
) {
motor.push({
...base,
summary: code === LIVE_EVENT_CODES.MOTOR_START ? 'start' : 'stop'
})
}
}
}
return {
pressure: sortPoints(pressure),
wind: sortPoints(wind),
motor: sortPoints(motor)
}
}
+92 -13
View File
@@ -47,6 +47,8 @@ function buildEncryptedPayload(
events: LogEventPayload[]
departure?: string
destination?: string
freshwater?: { morning: number; refilled: number; evening: number; consumption: number }
fuel?: { morning: number; refilled: number; evening: number; consumption: number }
clearSignatures?: boolean
}
): Record<string, unknown> {
@@ -56,23 +58,26 @@ function buildEncryptedPayload(
const trackSpeedAvg = data.trackSpeedAvgKn
const motorHoursRaw = data.motorHours
const freshwater = options.freshwater ?? {
morning: fw.morning || 0,
refilled: fw.refilled || 0,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
}
const fuelLevels = options.fuel ?? {
morning: fuel.morning || 0,
refilled: fuel.refilled || 0,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
}
const payload = buildLogEntryPayload({
date: String(data.date || ''),
dayOfTravel: String(data.dayOfTravel || ''),
departure: options.departure ?? String(data.departure || ''),
destination: options.destination ?? String(data.destination || ''),
freshwater: {
morning: fw.morning || 0,
refilled: fw.refilled || 0,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
},
fuel: {
morning: fuel.morning || 0,
refilled: fuel.refilled || 0,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
},
freshwater,
fuel: fuelLevels,
greywater: gw ? { level: gw.level || 0 } : undefined,
trackDistanceNm:
trackDistance != null && trackDistance !== ''
@@ -207,13 +212,28 @@ export async function appendQuickEvent(
})
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
const entryData = buildEncryptedPayload(loaded.data, {
await persistEntry(logbookId, entryId, loaded.data, {
events: nextEvents,
departure: headerPatch?.departure,
destination: headerPatch?.destination,
clearSignatures: hadSignature
})
return { events: nextEvents, hadSignature }
}
async function persistEntry(
logbookId: string,
entryId: string,
data: Record<string, unknown>,
options: Parameters<typeof buildEncryptedPayload>[1]
): Promise<void> {
const hadSignature = !!(data.signSkipper || data.signCrew)
const entryData = buildEncryptedPayload(data, {
...options,
clearSignatures: options.clearSignatures ?? hadSignature
})
const masterKey = await getMasterKey(logbookId)
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
@@ -237,6 +257,65 @@ export async function appendQuickEvent(
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
export async function removeLastEvent(
logbookId: string,
entryId: string
): Promise<LogEventPayload[]> {
const loaded = await loadEntry(logbookId, entryId)
if (!loaded) throw new Error('Entry not found')
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
if (currentEvents.length === 0) return []
const nextEvents = sortLogEventsByTime(currentEvents.slice(0, -1))
await persistEntry(logbookId, entryId, loaded.data, { events: nextEvents })
return nextEvents
}
export async function appendTankRefill(
logbookId: string,
entryId: string,
tank: 'fuel' | 'freshwater',
addLiters: number,
event: Partial<LogEventPayload>
): Promise<AppendQuickEventResult> {
const loaded = await loadEntry(logbookId, entryId)
if (!loaded) throw new Error('Entry not found')
const { fw, fuel } = tankLevelsFromData(loaded.data)
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
const newEvent = normalizeLogEvent({
time: currentLocalTimeHHMM(),
...event
})
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
const tankPatch = tank === 'fuel'
? {
fuel: {
morning: fuel.morning || 0,
refilled: (fuel.refilled || 0) + addLiters,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
}
}
: {
freshwater: {
morning: fw.morning || 0,
refilled: (fw.refilled || 0) + addLiters,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
}
}
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
await persistEntry(logbookId, entryId, loaded.data, {
events: nextEvents,
...tankPatch,
clearSignatures: hadSignature
})
return { events: nextEvents, hadSignature }
}
@@ -20,6 +20,10 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_fix': 'Fix',
'logs.live_fix_coords': `Fix ${opts?.lat}, ${opts?.lng}`,
'logs.live_event_generic': 'Event',
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
'logs.live_wind_entry': `Wind ${opts?.value}`,
'logs.live_course_entry': `Course ${opts?.course}`,
'logs.event_mgk': 'Course',
'logs.event_wind_pressure': 'Pressure'
}
@@ -74,4 +78,13 @@ describe('formatEventSummary', () => {
})
expect(formatEventSummary(event, t)).toBe('Fix 54.323000, 10.145000')
})
it('formats pressure entry', () => {
const event = normalizeLogEvent({
time: '09:00',
remarks: LIVE_EVENT_CODES.PRESSURE,
windPressure: '1013'
})
expect(formatEventSummary(event, t)).toBe('Pressure 1013 hPa')
})
})
+42 -4
View File
@@ -3,7 +3,11 @@ import type { LogEventPayload } from './logEntryPayload.js'
import {
LIVE_EVENT_CODES,
parseLiveCommentRemark,
parseLiveSailsRemark
parseLiveFuelRemark,
parseLivePrecipRemark,
parseLiveSailsRemark,
parseLiveTempRemark,
parseLiveWaterRemark
} from './liveEventCodes.js'
export function formatEventSummary(event: LogEventPayload, t: TFunction): string {
@@ -20,11 +24,45 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
const comment = parseLiveCommentRemark(code)
if (comment) return comment
if (code === LIVE_EVENT_CODES.FIX) {
const temp = parseLiveTempRemark(code)
if (temp) return t('logs.live_temp_entry', { temp })
const precip = parseLivePrecipRemark(code)
if (precip) return t('logs.live_precip_entry', { value: precip })
const fuel = parseLiveFuelRemark(code)
if (fuel) return t('logs.live_fuel_entry', { liters: fuel })
const water = parseLiveWaterRemark(code)
if (water) return t('logs.live_water_entry', { liters: water })
if (code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION) {
if (event.gpsLat && event.gpsLng) {
return t('logs.live_fix_coords', { lat: event.gpsLat, lng: event.gpsLng })
const label = code === LIVE_EVENT_CODES.AUTO_POSITION
? t('logs.live_auto_position')
: t('logs.live_fix')
return `${label} ${event.gpsLat}, ${event.gpsLng}`
}
return t('logs.live_fix')
return code === LIVE_EVENT_CODES.AUTO_POSITION
? t('logs.live_auto_position')
: t('logs.live_fix')
}
if (code === LIVE_EVENT_CODES.COURSE && event.mgk) {
return t('logs.live_course_entry', { course: event.mgk })
}
if (code === LIVE_EVENT_CODES.WIND) {
const wind = [event.windDirection, event.windStrength].filter(Boolean).join(' ')
return wind ? t('logs.live_wind_entry', { value: wind }) : t('logs.live_wind_btn')
}
if (code === LIVE_EVENT_CODES.PRESSURE && event.windPressure) {
return t('logs.live_pressure_entry', { value: event.windPressure })
}
if (code === LIVE_EVENT_CODES.SEA_STATE && event.seaState) {
return t('logs.live_sea_state_entry', { value: event.seaState })
}
if (code && !code.startsWith('__live:')) {
+63 -1
View File
@@ -4,7 +4,12 @@ export const LIVE_EVENT_CODES = {
MOTOR_STOP: '__live:motor_stop',
CAST_OFF: '__live:cast_off',
MOOR: '__live:moor',
FIX: '__live:fix'
FIX: '__live:fix',
AUTO_POSITION: '__live:auto_position',
COURSE: '__live:course',
WIND: '__live:wind',
PRESSURE: '__live:pressure',
SEA_STATE: '__live:sea_state'
} as const
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
@@ -17,6 +22,22 @@ export function liveCommentRemark(text: string): string {
return `__live:comment:${text}`
}
export function liveTempRemark(tempC: string): string {
return `__live:temp:${tempC}`
}
export function livePrecipRemark(text: string): string {
return `__live:precip:${text}`
}
export function liveFuelRemark(liters: string): string {
return `__live:fuel:${liters}`
}
export function liveWaterRemark(liters: string): string {
return `__live:water:${liters}`
}
export function parseLiveSailsRemark(remarks: string): string | null {
const prefix = '__live:sails:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
@@ -27,6 +48,26 @@ export function parseLiveCommentRemark(remarks: string): string | null {
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveTempRemark(remarks: string): string | null {
const prefix = '__live:temp:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLivePrecipRemark(remarks: string): string | null {
const prefix = '__live:precip:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveFuelRemark(remarks: string): string | null {
const prefix = '__live:fuel:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveWaterRemark(remarks: string): string | null {
const prefix = '__live:water:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
/** Derive motor running state from event history (survives reload). */
export function isMotorRunningFromEvents(
events: Array<{ remarks: string }>,
@@ -40,3 +81,24 @@ export function isMotorRunningFromEvents(
}
return false
}
export function eventTimestampMs(date: string, time: string): number | null {
const normalized = time.trim().match(/^(\d{1,2}):(\d{2})/)
if (!normalized || !date) return null
const hours = parseInt(normalized[1], 10)
const minutes = parseInt(normalized[2], 10)
if (hours > 23 || minutes > 59) return null
const parsed = new Date(`${date}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`)
return Number.isNaN(parsed.getTime()) ? null : parsed.getTime()
}
export function getLastAutoPositionMs(
events: Array<{ remarks: string; time: string }>,
entryDate: string
): number | null {
for (let i = events.length - 1; i >= 0; i--) {
if (events[i].remarks.trim() !== LIVE_EVENT_CODES.AUTO_POSITION) continue
return eventTimestampMs(entryDate, events[i].time)
}
return null
}