import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge, Timer } from 'lucide-react'
import MultiTrackMap from './MultiTrackMap.tsx'
import {
formatLiters,
formatHours,
formatNm,
loadAccountStats,
loadLogbookStats,
type LogbookStatsSummary,
type StatsTotals,
type TravelDayStats
} from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { formatAppDecimal } from '../utils/numberFormat.js'
import {
loadLogbookEventSeries,
type EventSeriesPoint,
type EventSeriesSummary
} from '../services/eventSeriesAggregation.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.motor_hours_total')}
value={formatHours(totals.totalMotorHours)}
unit={t('stats.unit_h')}
/>
}
label={t('stats.fuel_total')}
value={formatLiters(totals.totalFuelL)}
unit={t('stats.unit_l')}
/>
{totals.fuelPerMotorHourL != null && (
}
label={t('stats.fuel_per_motor_hour')}
value={formatFuelPerMotorHour(totals.fuelPerMotorHourL)}
unit={`${t('stats.unit_l')}/${t('stats.unit_h')}`}
/>
)}
}
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')} ({formatAppDecimal(sailPct, { maximumFractionDigits: 0 })}%)
{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)
{totals.unknownPropulsionNm > 0 && (
{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}
)}
{t('stats.propulsion_hint')}
)
}
function EventSeriesList({ title, points, emptyLabel }: { title: string; points: EventSeriesPoint[]; emptyLabel: string }) {
if (points.length === 0) {
return (
)
}
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
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_motor_hours')}
{t('stats.avg_motor_hours')}: {formatHours(totals.avgMotorHoursPerDay)} {t('stats.unit_h')}
{totals.fuelPerMotorHourL != null && (
<>
{' · '}
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
>
)}
d.motorHours}
barClass="stats-bar--motor-hours"
formatValue={formatHours}
/>
{travelDays.some((d) => d.fuelPerMotorHourL != null) && (
<>
{t('stats.daily_fuel_per_motor_hour')}
d.fuelPerMotorHourL ?? 0}
barClass="stats-bar--fuel-per-hour"
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
/>
>
)}
{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')}>
)}
{totals.fuelPerMotorHourL != null && (
<> · {t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}>
)}
{t('stats.propulsion_title')}
{eventSeries && }
>
)
}
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 [eventSeries, setEventSeries] = useState(null)
const [accountStats, setAccountStats] = useState> | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [lb, acc, series] = await Promise.all([
loadLogbookStats(logbookId, logbookTitle, true),
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.')
} 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 ? (
) : 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.motor_hours_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')} |
{formatHours(lb.totals.totalMotorHours)} {t('stats.unit_h')} |
{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_motor_hours')}
{accountStats.totals.fuelPerMotorHourL != null && (
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
)}
d.motorHours}
barClass="stats-bar--motor-hours"
formatValue={formatHours}
/>
{allAccountDays.some((d) => d.fuelPerMotorHourL != null) && (
<>
{t('stats.daily_fuel_per_motor_hour')}
d.fuelPerMotorHourL ?? 0}
barClass="stats-bar--fuel-per-hour"
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
/>
>
)}
{t('stats.daily_consumption')}
{accountStats.totals.fuelPerMotorHourL != null && (
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
)}
{t('stats.propulsion_title')}
>
)}
>
)}
>
) : null}
)
}