diff --git a/client/src/App.css b/client/src/App.css index d24533d..14a4f21 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -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 { diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index bd5b4dd..7241fd7 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -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(null) const [modal, setModal] = useState('none') + const [weatherExpanded, setWeatherExpanded] = useState(false) const [commentText, setCommentText] = useState('') + const [valueInput, setValueInput] = useState('') + const [valueInputSecondary, setValueInputSecondary] = useState('') const [selectedSails, setSelectedSails] = useState([]) + const [undoVisible, setUndoVisible] = useState(false) const streamEndRef = useRef(null) + const undoTimerRef = useRef(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, - 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 (
@@ -252,22 +442,12 @@ export default function LiveLogView({
- {entryId && ( - @@ -279,12 +459,7 @@ export default function LiveLogView({
+ {undoVisible && events.length > 0 && ( +
+ {t('logs.live_undo_hint')} + +
+ )} + {modal === 'sails' && (
setModal('none')}>
e.stopPropagation()}> @@ -355,12 +576,8 @@ export default function LiveLogView({ ))}
- - + +
@@ -370,22 +587,61 @@ export default function LiveLogView({
setModal('none')}>
e.stopPropagation()}>

{t('logs.live_comment_btn')}

+ setCommentText(e.target.value)} placeholder={t('logs.live_comment_placeholder')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }} /> +
+ + +
+
+
+ )} + + {modal === 'wind' && ( +
setModal('none')}> +
e.stopPropagation()}> +

{t('logs.live_wind_btn')}

+ setValueInput(e.target.value)} placeholder={t('logs.event_wind_direction')} autoFocus /> + setValueInputSecondary(e.target.value)} placeholder={t('logs.event_wind_strength')} /> +
+ + +
+
+
+ )} + + {['pressure', 'temp', 'precip', 'sea_state', 'course', 'fuel', 'water'].includes(modal) && ( +
setModal('none')}> +
e.stopPropagation()}> +

+ {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')} +

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() }} />
- - + +
diff --git a/client/src/components/StatsDashboard.tsx b/client/src/components/StatsDashboard.tsx index 16f1ec3..003f794 100644 --- a/client/src/components/StatsDashboard.tsx +++ b/client/src/components/StatsDashboard.tsx @@ -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 ( +
+

{title}

+

{emptyLabel}

+
+ ) + } + + return ( +
+

{title}

+
    + {points.map((point, idx) => ( +
  • + + {new Date(point.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })} + {' · '} + {point.time} + + {point.summary} +
  • + ))} +
+
+ ) +} + +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 ( +
+

{t('stats.event_series_title')}

+

{t('stats.event_series_hint')}

+ + + +
+ ) +} + +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 }) {

{t('stats.propulsion_title')}

+ + {eventSeries && } ) } @@ -323,18 +385,21 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [logbookStats, setLogbookStats] = useState(null) + const [eventSeries, setEventSeries] = useState(null) const [accountStats, setAccountStats] = useState> | 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

{t('stats.loading')}

) : scope === 'logbook' && logbookStats ? ( - + ) : scope === 'account' && accountStats ? ( <> diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index 5e18a5d..5c621c1 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -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", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 103d96f..72b9387 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -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", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 785c60b..19055e4 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 8cb7b72..6133937 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -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", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 22d298f..989fc72 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -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", diff --git a/client/src/services/eventSeriesAggregation.ts b/client/src/services/eventSeriesAggregation.ts new file mode 100644 index 0000000..98bb2c2 --- /dev/null +++ b/client/src/services/eventSeriesAggregation.ts @@ -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 { + 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) + } +} diff --git a/client/src/services/quickEventLog.ts b/client/src/services/quickEventLog.ts index ab7a0a9..0ccd3d3 100644 --- a/client/src/services/quickEventLog.ts +++ b/client/src/services/quickEventLog.ts @@ -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 { @@ -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, + options: Parameters[1] +): Promise { + 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 { + 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 +): Promise { + 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 } } diff --git a/client/src/utils/formatEventSummary.test.ts b/client/src/utils/formatEventSummary.test.ts index 1de3d13..b24b8a3 100644 --- a/client/src/utils/formatEventSummary.test.ts +++ b/client/src/utils/formatEventSummary.test.ts @@ -20,6 +20,10 @@ const t = (key: string, opts?: Record) => { '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') + }) }) diff --git a/client/src/utils/formatEventSummary.ts b/client/src/utils/formatEventSummary.ts index 060d8fb..315b22a 100644 --- a/client/src/utils/formatEventSummary.ts +++ b/client/src/utils/formatEventSummary.ts @@ -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:')) { diff --git a/client/src/utils/liveEventCodes.ts b/client/src/utils/liveEventCodes.ts index 6f42ef4..230081e 100644 --- a/client/src/utils/liveEventCodes.ts +++ b/client/src/utils/liveEventCodes.ts @@ -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 +}