(null)
+
+ const segmentsKey = useMemo(
+ () =>
+ segments
+ .map((seg) =>
+ seg.waypoints
+ .filter(isValidWaypoint)
+ .map((wp) => `${seg.entryId}:${wp.lat},${wp.lng}`)
+ .join('|')
+ )
+ .join('||'),
+ [segments]
+ )
+
+ useEffect(() => {
+ const container = containerRef.current
+ if (!container || segments.length === 0) return
+
+ let cancelled = false
+ const pendingFrames: number[] = []
+
+ const map = L.map(container, {
+ zoomControl: true,
+ attributionControl: true
+ })
+
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 19,
+ attribution: '© OpenStreetMap contributors'
+ }).addTo(map)
+
+ L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
+ maxZoom: 18,
+ attribution: 'Map data © OpenSeaMap contributors'
+ }).addTo(map)
+
+ const trackGroup = L.layerGroup().addTo(map)
+ const allLatLngs: [number, number][] = []
+
+ for (const segment of segments) {
+ const latLngs = toLatLngs(segment.waypoints)
+ if (latLngs.length < 2) continue
+
+ allLatLngs.push(...latLngs)
+ const color = getTrackColor(segment.colorIndex)
+
+ L.polyline(latLngs, {
+ color,
+ weight: LINE_WEIGHT,
+ opacity: LINE_OPACITY,
+ lineCap: 'round',
+ lineJoin: 'round'
+ })
+ .addTo(trackGroup)
+ .bindPopup(t('stats.day_label', { day: segment.dayOfTravel }))
+
+ L.circleMarker(latLngs[0], {
+ radius: 7,
+ fillColor: color,
+ fillOpacity: 0.95,
+ color: '#ffffff',
+ weight: 2
+ })
+ .addTo(trackGroup)
+ .bindPopup(`${t('stats.day_label', { day: segment.dayOfTravel })} – ${t('logs.track_map_start')}`)
+ }
+
+ if (allLatLngs.length > 0) {
+ pendingFrames.push(
+ requestAnimationFrame(() => {
+ if (cancelled) return
+ map.invalidateSize({ animate: false })
+ pendingFrames.push(
+ requestAnimationFrame(() => {
+ if (cancelled) return
+ try {
+ const bounds = L.latLngBounds(allLatLngs.map(([lat, lng]) => L.latLng(lat, lng)))
+ if (bounds.isValid()) {
+ map.fitBounds(bounds, { padding: [24, 24], maxZoom: 12, animate: false })
+ }
+ } catch {
+ map.setView(allLatLngs[0], 11, { animate: false })
+ }
+ })
+ )
+ })
+ )
+ }
+
+ return () => {
+ cancelled = true
+ pendingFrames.forEach((id) => cancelAnimationFrame(id))
+ map.remove()
+ }
+ }, [segmentsKey, segments, t])
+
+ if (segments.length === 0) return null
+
+ return (
+
+
+
+ {segments.map((seg) => (
+
+
+ {t('stats.day_label', { day: seg.dayOfTravel })}
+
+ ))}
+
+
+ )
+}
+
+class MultiTrackMapErrorBoundary extends Component<
+ { children: ReactNode; fallback: ReactNode },
+ { hasError: boolean }
+> {
+ state = { hasError: false }
+
+ static getDerivedStateFromError() {
+ return { hasError: true }
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ console.error('MultiTrackMap render failed:', error, info)
+ }
+
+ render() {
+ if (this.state.hasError) return this.props.fallback
+ return this.props.children
+ }
+}
+
+export default function MultiTrackMap(props: MultiTrackMapProps) {
+ const { t } = useTranslation()
+
+ return (
+ {t('logs.track_map_error')}}
+ >
+
+
+ )
+}
diff --git a/client/src/components/StatsDashboard.tsx b/client/src/components/StatsDashboard.tsx
new file mode 100644
index 0000000..5533f96
--- /dev/null
+++ b/client/src/components/StatsDashboard.tsx
@@ -0,0 +1,417 @@
+import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
+import { useTranslation } from 'react-i18next'
+import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge } from 'lucide-react'
+import MultiTrackMap from './MultiTrackMap.tsx'
+import {
+ formatLiters,
+ formatNm,
+ loadAccountStats,
+ loadLogbookStats,
+ type LogbookStatsSummary,
+ type StatsTotals,
+ type TravelDayStats
+} from '../services/statsAggregation.js'
+import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
+
+interface StatsDashboardProps {
+ logbookId: string
+ logbookTitle: string
+}
+
+type StatsScope = 'logbook' | 'account'
+
+function maxBarValue(days: TravelDayStats[], pick: (d: TravelDayStats) => number): number {
+ if (days.length === 0) return 1
+ return Math.max(1, ...days.map(pick))
+}
+
+function KpiCard({
+ icon,
+ label,
+ value,
+ unit
+}: {
+ icon: ReactNode
+ label: string
+ value: string
+ unit?: string
+}) {
+ return (
+
+
{icon}
+
+ {label}
+
+ {value}
+ {unit ? {unit} : null}
+
+
+
+ )
+}
+
+function TotalsGrid({ totals }: { totals: StatsTotals }) {
+ const { t } = useTranslation()
+
+ return (
+
+ }
+ label={t('stats.total_distance')}
+ value={formatNm(totals.totalDistanceNm)}
+ unit={t('stats.unit_nm')}
+ />
+ }
+ label={t('stats.travel_days')}
+ value={String(totals.travelDayCount)}
+ />
+ }
+ label={t('stats.sail_distance')}
+ value={formatNm(totals.sailDistanceNm)}
+ unit={t('stats.unit_nm')}
+ />
+ }
+ label={t('stats.motor_distance')}
+ value={formatNm(totals.motorDistanceNm)}
+ unit={t('stats.unit_nm')}
+ />
+ }
+ label={t('stats.fuel_total')}
+ value={formatLiters(totals.totalFuelL)}
+ unit={t('stats.unit_l')}
+ />
+ }
+ label={t('stats.water_total')}
+ value={formatLiters(totals.totalFreshwaterL)}
+ unit={t('stats.unit_l')}
+ />
+
+ )
+}
+
+function DailyBarChart({
+ days,
+ valueFn,
+ barClass,
+ formatValue
+}: {
+ days: TravelDayStats[]
+ valueFn: (d: TravelDayStats) => number
+ barClass: string
+ formatValue: (v: number) => string
+}) {
+ const { t } = useTranslation()
+ const max = maxBarValue(days, valueFn)
+
+ return (
+
+ {days.map((day) => {
+ const value = valueFn(day)
+ const heightPct = max > 0 ? Math.max(2, (value / max) * 100) : 0
+ const label = day.date
+ ? new Date(day.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })
+ : t('stats.day_label', { day: day.dayOfTravel })
+
+ return (
+
+
{value > 0 ? formatValue(value) : ''}
+
+
{label}
+
{t('stats.day_label', { day: day.dayOfTravel })}
+
+ )
+ })}
+
+ )
+}
+
+function ConsumptionChart({ days }: { days: TravelDayStats[] }) {
+ const { t } = useTranslation()
+ const max = maxBarValue(days, (d) => Math.max(d.fuelConsumptionL, d.freshwaterConsumptionL))
+
+ return (
+
+ {days.map((day) => {
+ const fuelH = max > 0 ? Math.max(2, (day.fuelConsumptionL / max) * 100) : 0
+ const waterH = max > 0 ? Math.max(2, (day.freshwaterConsumptionL / max) * 100) : 0
+ const label = day.date
+ ? new Date(day.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })
+ : t('stats.day_label', { day: day.dayOfTravel })
+
+ return (
+
+ )
+ })}
+
+ {t('stats.fuel_legend')}
+ {t('stats.water_legend')}
+
+
+ )
+}
+
+function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
+ const { t } = useTranslation()
+ const total = totals.sailDistanceNm + totals.motorDistanceNm + totals.unknownPropulsionNm
+ if (total <= 0) return null
+
+ const sailPct = (totals.sailDistanceNm / total) * 100
+ const motorPct = (totals.motorDistanceNm / total) * 100
+ const unknownPct = (totals.unknownPropulsionNm / total) * 100
+
+ return (
+
+
+ {totals.sailDistanceNm > 0 && (
+
+ )}
+ {totals.motorDistanceNm > 0 && (
+
+ )}
+ {totals.unknownPropulsionNm > 0 && (
+
+ )}
+
+
+ {t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)
+ {t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)
+ {totals.unknownPropulsionNm > 0 && (
+ {t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}
+ )}
+
+
{t('stats.propulsion_hint')}
+
+ )
+}
+
+function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
+ const { t } = useTranslation()
+ const { travelDays, routePorts, trackSegments, totals } = summary
+
+ if (travelDays.length === 0) {
+ return {t('stats.no_data')}
+ }
+
+ return (
+ <>
+
+
+ {routePorts.length > 0 && (
+
+
{t('stats.route_overview')}
+
+ {routePorts.map((port, idx) => (
+
+ {idx > 0 && → }
+ {port}
+
+ ))}
+
+
+ )}
+
+ {trackSegments.length > 0 && (
+
+
{t('stats.route_map_title')}
+
+
+ )}
+
+
+
{t('stats.daily_etmal')}
+
+ {t('stats.avg_distance')}: {formatNm(totals.avgDistancePerDayNm)} {t('stats.unit_nm')}
+
+
d.distanceNm}
+ barClass="stats-bar--distance"
+ formatValue={formatNm}
+ />
+
+
+
+
{t('stats.daily_consumption')}
+
+ {t('stats.avg_fuel')}: {formatLiters(totals.avgFuelPerDayL)} {t('stats.unit_l')}
+ {' · '}
+ {t('stats.avg_water')}: {formatLiters(totals.avgFreshwaterPerDayL)} {t('stats.unit_l')}
+ {totals.fuelPerNmL != null && (
+ <> · {t('stats.fuel_per_nm')}: {totals.fuelPerNmL} {t('stats.unit_l')}/{t('stats.unit_nm')}>
+ )}
+
+
+
+
+
+
{t('stats.propulsion_title')}
+
+
+ >
+ )
+}
+
+export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboardProps) {
+ const { t } = useTranslation()
+ const [scope, setScope] = useState('logbook')
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [logbookStats, setLogbookStats] = useState(null)
+ const [accountStats, setAccountStats] = useState> | null>(null)
+
+ const loadData = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const [lb, acc] = await Promise.all([
+ loadLogbookStats(logbookId, logbookTitle, true),
+ loadAccountStats(false)
+ ])
+ setLogbookStats(lb)
+ setAccountStats(acc)
+ } catch (err: unknown) {
+ console.error('Failed to load statistics:', err)
+ setError(err instanceof Error ? err.message : 'Failed to load statistics.')
+ } finally {
+ setLoading(false)
+ }
+ }, [logbookId, logbookTitle])
+
+ useEffect(() => {
+ void loadData()
+ }, [loadData])
+
+ const accountLogbooksWithDays = useMemo(
+ () => accountStats?.logbooks.filter((lb) => lb.travelDays.length > 0) ?? [],
+ [accountStats]
+ )
+
+ const allAccountDays = useMemo(() => {
+ if (!accountStats) return []
+ const days = accountStats.logbooks.flatMap((lb) => lb.travelDays)
+ return [...days].sort(compareTravelDaysChronological)
+ }, [accountStats])
+
+ return (
+
+
+
+
+
{t('stats.title')}
+
{t('stats.subtitle')}
+
+
+
+
+
+
+
+
+ {error &&
{error}
}
+
+ {loading ? (
+
+
+
{t('stats.loading')}
+
+ ) : scope === 'logbook' && logbookStats ? (
+
+ ) : scope === 'account' && accountStats ? (
+ <>
+
+
+ {accountLogbooksWithDays.length === 0 ? (
+
{t('stats.no_data')}
+ ) : (
+ <>
+
+
{t('stats.account_logbooks')}
+
+
+
+
+ | {t('stats.col_logbook')} |
+ {t('stats.travel_days')} |
+ {t('stats.total_distance')} |
+ {t('stats.fuel_total')} |
+ {t('stats.water_total')} |
+
+
+
+ {accountLogbooksWithDays.map((lb) => (
+
+ | {lb.title} |
+ {lb.totals.travelDayCount} |
+ {formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')} |
+ {formatLiters(lb.totals.totalFuelL)} {t('stats.unit_l')} |
+ {formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')} |
+
+ ))}
+
+
+
+
+
+ {accountStats.totals.travelDayCount > 0 && (
+ <>
+
+
{t('stats.daily_etmal')}
+ d.distanceNm}
+ barClass="stats-bar--distance"
+ formatValue={formatNm}
+ />
+
+
+
+
{t('stats.daily_consumption')}
+
+
+
+
+
{t('stats.propulsion_title')}
+
+
+ >
+ )}
+ >
+ )}
+ >
+ ) : null}
+
+ )
+}
diff --git a/client/src/context/AppTourContext.tsx b/client/src/context/AppTourContext.tsx
index b2519ae..4181f52 100644
--- a/client/src/context/AppTourContext.tsx
+++ b/client/src/context/AppTourContext.tsx
@@ -16,7 +16,7 @@ import {
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
-export type AppTab = 'vessel' | 'crew' | 'logs' | 'settings'
+export type AppTab = 'vessel' | 'crew' | 'logs' | 'stats' | 'settings'
export type TourStepId =
| 'welcome'
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json
index 504d60b..7aa1ffc 100644
--- a/client/src/i18n/locales/de.json
+++ b/client/src/i18n/locales/de.json
@@ -10,6 +10,7 @@
"crew": "Crew-Liste",
"deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge",
+ "stats": "Statistik",
"settings": "Einstellungen"
},
"auth": {
@@ -337,6 +338,39 @@
"logbook_title": "Demo-Logbuch Ostsee",
"badge": "Demo"
},
+ "stats": {
+ "title": "Statistik",
+ "subtitle": "Strecken, Verbrauch und Antriebsart auf einen Blick",
+ "scope_label": "Auswertungsbereich",
+ "scope_logbook": "Dieses Logbuch",
+ "scope_account": "Alle Logbücher",
+ "loading": "Statistik wird berechnet…",
+ "no_data": "Noch keine Reisetage vorhanden.",
+ "total_distance": "Gesamtstrecke",
+ "travel_days": "Reisetage",
+ "sail_distance": "Unter Segel",
+ "motor_distance": "Maschinenfahrt",
+ "unknown_propulsion": "Unbekannt",
+ "fuel_total": "Kraftstoff gesamt",
+ "water_total": "Wasser gesamt",
+ "daily_etmal": "Tages-Etmale",
+ "daily_consumption": "Tagesverbrauch",
+ "route_overview": "Route",
+ "route_map_title": "Streckenübersicht",
+ "propulsion_title": "Segel vs. Maschine",
+ "propulsion_hint": "Die Aufteilung basiert auf den Logbuch-Events pro Reisetag, nicht auf GPS-Segmenten.",
+ "avg_distance": "Ø pro Reisetag",
+ "avg_fuel": "Ø Kraftstoff",
+ "avg_water": "Ø Wasser",
+ "fuel_per_nm": "Kraftstoff pro sm",
+ "fuel_legend": "Kraftstoff",
+ "water_legend": "Wasser",
+ "unit_nm": "sm",
+ "unit_l": "L",
+ "day_label": "Tag {{day}}",
+ "account_logbooks": "Logbücher im Überblick",
+ "col_logbook": "Logbuch"
+ },
"tour": {
"skip": "Tour überspringen",
"back": "Zurück",
diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json
index 4855c36..06a42b7 100644
--- a/client/src/i18n/locales/en.json
+++ b/client/src/i18n/locales/en.json
@@ -10,6 +10,7 @@
"crew": "Crew List",
"deviation": "Deviation Table",
"logs": "Logbook Entries",
+ "stats": "Statistics",
"settings": "Settings"
},
"auth": {
@@ -337,6 +338,39 @@
"logbook_title": "Baltic Sea Demo Logbook",
"badge": "Demo"
},
+ "stats": {
+ "title": "Statistics",
+ "subtitle": "Routes, consumption and propulsion at a glance",
+ "scope_label": "Scope",
+ "scope_logbook": "This logbook",
+ "scope_account": "All logbooks",
+ "loading": "Calculating statistics…",
+ "no_data": "No travel days yet.",
+ "total_distance": "Total distance",
+ "travel_days": "Travel days",
+ "sail_distance": "Under sail",
+ "motor_distance": "Engine",
+ "unknown_propulsion": "Unknown",
+ "fuel_total": "Total fuel",
+ "water_total": "Total water",
+ "daily_etmal": "Daily mileage",
+ "daily_consumption": "Daily consumption",
+ "route_overview": "Route",
+ "route_map_title": "Route overview",
+ "propulsion_title": "Sail vs. engine",
+ "propulsion_hint": "Split is based on logbook events per travel day, not GPS segments.",
+ "avg_distance": "Avg. per travel day",
+ "avg_fuel": "Avg. fuel",
+ "avg_water": "Avg. water",
+ "fuel_per_nm": "Fuel per nm",
+ "fuel_legend": "Fuel",
+ "water_legend": "Water",
+ "unit_nm": "nm",
+ "unit_l": "L",
+ "day_label": "Day {{day}}",
+ "account_logbooks": "Logbooks overview",
+ "col_logbook": "Logbook"
+ },
"tour": {
"skip": "Skip tour",
"back": "Back",
diff --git a/client/src/services/statsAggregation.ts b/client/src/services/statsAggregation.ts
new file mode 100644
index 0000000..60b4eb4
--- /dev/null
+++ b/client/src/services/statsAggregation.ts
@@ -0,0 +1,251 @@
+import { db } from './db.js'
+import { getActiveMasterKey } from './auth.js'
+import { getLogbookKey } from './logbookKeys.js'
+import { decryptJson } from './crypto.js'
+import { decryptLogbookTitle } from './logbook.js'
+import { getDecryptedTrack, type TrackWaypoint } from './trackUpload.js'
+import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
+import type { LogEntryPayloadInput } from '../utils/logEntryPayload.js'
+import {
+ parseEventDistanceNm,
+ splitDistanceByPropulsion
+} from '../utils/propulsionStats.js'
+
+export type DistanceSource = 'gps' | 'events' | 'none'
+
+export interface TravelDayStats {
+ entryId: string
+ logbookId: string
+ date: string
+ dayOfTravel: string
+ departure: string
+ destination: string
+ distanceNm: number
+ distanceSource: DistanceSource
+ fuelConsumptionL: number
+ freshwaterConsumptionL: number
+ sailDistanceNm: number
+ motorDistanceNm: number
+ unknownPropulsionNm: number
+ hasGpsTrack: boolean
+}
+
+export interface TrackSegment {
+ entryId: string
+ dayOfTravel: string
+ label: string
+ waypoints: TrackWaypoint[]
+ colorIndex: number
+}
+
+export interface LogbookStatsSummary {
+ logbookId: string
+ title: string
+ travelDays: TravelDayStats[]
+ routePorts: string[]
+ trackSegments: TrackSegment[]
+ totals: StatsTotals
+}
+
+export interface AccountStatsSummary {
+ logbooks: LogbookStatsSummary[]
+ totals: StatsTotals
+}
+
+export interface StatsTotals {
+ travelDayCount: number
+ daysWithGps: number
+ totalDistanceNm: number
+ sailDistanceNm: number
+ motorDistanceNm: number
+ unknownPropulsionNm: number
+ totalFuelL: number
+ totalFreshwaterL: number
+ avgDistancePerDayNm: number
+ avgFuelPerDayL: number
+ avgFreshwaterPerDayL: number
+ fuelPerNmL: number | null
+}
+
+const TRACK_COLORS = [
+ '#3b82f6',
+ '#10b981',
+ '#f59e0b',
+ '#8b5cf6',
+ '#ec4899',
+ '#06b6d4',
+ '#ef4444',
+ '#84cc16'
+]
+
+function resolveDistanceNm(payload: LogEntryPayloadInput): { distanceNm: number; distanceSource: DistanceSource } {
+ const gpsDistance = Number(payload.trackDistanceNm) || 0
+ if (gpsDistance > 0) {
+ return { distanceNm: gpsDistance, distanceSource: 'gps' }
+ }
+
+ const eventSum = (payload.events || []).reduce(
+ (sum, event) => sum + parseEventDistanceNm(event.distance),
+ 0
+ )
+ if (eventSum > 0) {
+ return { distanceNm: Number(eventSum.toFixed(2)), distanceSource: 'events' }
+ }
+
+ return { distanceNm: 0, distanceSource: 'none' }
+}
+
+function buildTotals(days: TravelDayStats[]): StatsTotals {
+ const travelDayCount = days.length
+ const daysWithGps = days.filter((d) => d.hasGpsTrack).length
+ const totalDistanceNm = days.reduce((sum, d) => sum + d.distanceNm, 0)
+ const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0)
+ const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0)
+ const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 0)
+ const totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 0)
+ const totalFreshwaterL = days.reduce((sum, d) => sum + d.freshwaterConsumptionL, 0)
+
+ return {
+ travelDayCount,
+ daysWithGps,
+ totalDistanceNm: Number(totalDistanceNm.toFixed(2)),
+ sailDistanceNm: Number(sailDistanceNm.toFixed(2)),
+ motorDistanceNm: Number(motorDistanceNm.toFixed(2)),
+ unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)),
+ totalFuelL: Number(totalFuelL.toFixed(1)),
+ totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)),
+ avgDistancePerDayNm:
+ travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0,
+ avgFuelPerDayL:
+ travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0,
+ avgFreshwaterPerDayL:
+ travelDayCount > 0 ? Number((totalFreshwaterL / travelDayCount).toFixed(1)) : 0,
+ fuelPerNmL:
+ totalDistanceNm > 0 && totalFuelL > 0
+ ? Number((totalFuelL / totalDistanceNm).toFixed(2))
+ : null
+ }
+}
+
+export function buildRoutePorts(days: TravelDayStats[]): string[] {
+ const ports: string[] = []
+ for (const day of days) {
+ const dep = day.departure.trim()
+ const dest = day.destination.trim()
+ if (dep && (ports.length === 0 || ports[ports.length - 1] !== dep)) {
+ ports.push(dep)
+ }
+ if (dest && (ports.length === 0 || ports[ports.length - 1] !== dest)) {
+ ports.push(dest)
+ }
+ }
+ return ports
+}
+
+async function loadTravelDaysForLogbook(
+ logbookId: string,
+ includeTracks: boolean
+): Promise<{ days: TravelDayStats[]; trackSegments: TrackSegment[] }> {
+ const masterKey = (await getLogbookKey(logbookId)) || getActiveMasterKey()
+ if (!masterKey) {
+ throw new Error('Encryption key not found. Please log in.')
+ }
+
+ const localEntries = await db.entries.where({ logbookId }).toArray()
+ const days: TravelDayStats[] = []
+ const trackSegments: TrackSegment[] = []
+
+ for (const entry of localEntries) {
+ const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
+ if (!decrypted) continue
+
+ const payload = decrypted as LogEntryPayloadInput
+ const { distanceNm, distanceSource } = resolveDistanceNm(payload)
+ const propulsion = splitDistanceByPropulsion(distanceNm, payload.events || [])
+
+ let hasGpsTrack = false
+ if (includeTracks) {
+ const track = await getDecryptedTrack(entry.payloadId)
+ if (track && track.waypoints.length >= 2) {
+ hasGpsTrack = true
+ trackSegments.push({
+ entryId: entry.payloadId,
+ dayOfTravel: payload.dayOfTravel || '',
+ label: payload.dayOfTravel || '?',
+ waypoints: track.waypoints,
+ colorIndex: trackSegments.length % TRACK_COLORS.length
+ })
+ }
+ } else {
+ hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId))
+ }
+
+ days.push({
+ entryId: entry.payloadId,
+ logbookId,
+ date: payload.date || '',
+ dayOfTravel: payload.dayOfTravel || '',
+ departure: payload.departure || '',
+ destination: payload.destination || '',
+ distanceNm,
+ distanceSource,
+ fuelConsumptionL: Number(payload.fuel?.consumption) || 0,
+ freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0,
+ sailDistanceNm: propulsion.sailDistanceNm,
+ motorDistanceNm: propulsion.motorDistanceNm,
+ unknownPropulsionNm: propulsion.unknownPropulsionNm,
+ hasGpsTrack
+ })
+ }
+
+ days.sort(compareTravelDaysChronological)
+ trackSegments.sort((a, b) => Number(a.dayOfTravel) - Number(b.dayOfTravel))
+
+ return { days, trackSegments }
+}
+
+export async function loadLogbookStats(
+ logbookId: string,
+ title: string,
+ includeTracks = true
+): Promise {
+ const { days, trackSegments } = await loadTravelDaysForLogbook(logbookId, includeTracks)
+ return {
+ logbookId,
+ title,
+ travelDays: days,
+ routePorts: buildRoutePorts(days),
+ trackSegments,
+ totals: buildTotals(days)
+ }
+}
+
+export async function loadAccountStats(includeTracks = false): Promise {
+ const logbooks = await db.logbooks.toArray()
+ const summaries: LogbookStatsSummary[] = []
+
+ for (const lb of logbooks) {
+ const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
+ summaries.push(await loadLogbookStats(lb.id, title, includeTracks))
+ }
+
+ summaries.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
+
+ const allDays = summaries.flatMap((s) => s.travelDays)
+ return {
+ logbooks: summaries,
+ totals: buildTotals(allDays)
+ }
+}
+
+export function getTrackColor(index: number): string {
+ return TRACK_COLORS[index % TRACK_COLORS.length]
+}
+
+export function formatNm(value: number): string {
+ return value.toFixed(2)
+}
+
+export function formatLiters(value: number): string {
+ return Number.isInteger(value) ? String(value) : value.toFixed(1)
+}
diff --git a/client/src/utils/propulsionStats.ts b/client/src/utils/propulsionStats.ts
new file mode 100644
index 0000000..3dc6315
--- /dev/null
+++ b/client/src/utils/propulsionStats.ts
@@ -0,0 +1,58 @@
+import type { LogEventPayload } from './logEntryPayload.js'
+
+export type PropulsionMode = 'sail' | 'motor'
+
+const MOTOR_LABELS = ['Maschinenfahrt', 'Engine Propulsion']
+
+export function isMotorPropulsion(sailsOrMotor: string): boolean {
+ const normalized = sailsOrMotor.trim().toLowerCase()
+ if (!normalized) return false
+ return MOTOR_LABELS.some((label) => normalized.includes(label.toLowerCase()))
+}
+
+export function classifyEventPropulsion(event: Pick): PropulsionMode {
+ return isMotorPropulsion(event.sailsOrMotor) ? 'motor' : 'sail'
+}
+
+export interface PropulsionDistanceSplit {
+ sailDistanceNm: number
+ motorDistanceNm: number
+ unknownPropulsionNm: number
+}
+
+export function splitDistanceByPropulsion(
+ distanceNm: number,
+ events: Pick[]
+): PropulsionDistanceSplit {
+ if (distanceNm <= 0) {
+ return { sailDistanceNm: 0, motorDistanceNm: 0, unknownPropulsionNm: 0 }
+ }
+
+ const classified = events.filter((e) => e.sailsOrMotor.trim())
+ if (classified.length === 0) {
+ return { sailDistanceNm: 0, motorDistanceNm: 0, unknownPropulsionNm: distanceNm }
+ }
+
+ let motorCount = 0
+ let sailCount = 0
+ for (const event of classified) {
+ if (isMotorPropulsion(event.sailsOrMotor)) {
+ motorCount++
+ } else {
+ sailCount++
+ }
+ }
+
+ const total = motorCount + sailCount
+ const motorDistanceNm = Number(((distanceNm * motorCount) / total).toFixed(2))
+ const sailDistanceNm = Number((distanceNm - motorDistanceNm).toFixed(2))
+
+ return { sailDistanceNm, motorDistanceNm, unknownPropulsionNm: 0 }
+}
+
+export function parseEventDistanceNm(distance: string): number {
+ const match = distance.replace(',', '.').match(/(\d+(?:\.\d+)?)/)
+ if (!match) return 0
+ const value = Number(match[1])
+ return Number.isFinite(value) && value > 0 ? value : 0
+}