Files
kapteins-daagbok/client/src/components/StatsDashboard.tsx
T
elpatron 3cab735754 refactor: replace parseFloat with parseAppDecimal and formatAppDecimal for improved number handling
Updated various components to utilize parseAppDecimal and formatAppDecimal for consistent decimal parsing and formatting. This change enhances the handling of numeric inputs across the application, ensuring better accuracy and user experience in forms and displays.
2026-06-03 18:07:22 +02:00

566 lines
21 KiB
TypeScript

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 (
<div className="stats-kpi-card glass">
<div className="stats-kpi-icon">{icon}</div>
<div className="stats-kpi-body">
<span className="stats-kpi-label">{label}</span>
<span className="stats-kpi-value">
{value}
{unit ? <span className="stats-kpi-unit">{unit}</span> : null}
</span>
</div>
</div>
)
}
function TotalsGrid({ totals }: { totals: StatsTotals }) {
const { t } = useTranslation()
return (
<div className="stats-kpi-grid">
<KpiCard
icon={<Gauge size={20} />}
label={t('stats.total_distance')}
value={formatNm(totals.totalDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Anchor size={20} />}
label={t('stats.travel_days')}
value={String(totals.travelDayCount)}
/>
<KpiCard
icon={<Sailboat size={20} />}
label={t('stats.sail_distance')}
value={formatNm(totals.sailDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Gauge size={20} />}
label={t('stats.motor_distance')}
value={formatNm(totals.motorDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Timer size={20} />}
label={t('stats.motor_hours_total')}
value={formatHours(totals.totalMotorHours)}
unit={t('stats.unit_h')}
/>
<KpiCard
icon={<Fuel size={20} />}
label={t('stats.fuel_total')}
value={formatLiters(totals.totalFuelL)}
unit={t('stats.unit_l')}
/>
{totals.fuelPerMotorHourL != null && (
<KpiCard
icon={<Timer size={20} />}
label={t('stats.fuel_per_motor_hour')}
value={formatFuelPerMotorHour(totals.fuelPerMotorHourL)}
unit={`${t('stats.unit_l')}/${t('stats.unit_h')}`}
/>
)}
<KpiCard
icon={<Droplets size={20} />}
label={t('stats.water_total')}
value={formatLiters(totals.totalFreshwaterL)}
unit={t('stats.unit_l')}
/>
</div>
)
}
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 (
<div className="stats-bar-chart" role="img" aria-label={t('stats.daily_etmal')}>
{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 (
<div key={day.entryId} className="stats-bar-column" title={`${label}: ${formatValue(value)}`}>
<span className="stats-bar-value">{value > 0 ? formatValue(value) : ''}</span>
<div className="stats-bar-track">
<div className={`stats-bar ${barClass}`} style={{ height: `${heightPct}%` }} />
</div>
<span className="stats-bar-label">{label}</span>
<span className="stats-bar-sublabel">{t('stats.day_label', { day: day.dayOfTravel })}</span>
</div>
)
})}
</div>
)
}
function ConsumptionChart({ days }: { days: TravelDayStats[] }) {
const { t } = useTranslation()
const max = maxBarValue(days, (d) => Math.max(d.fuelConsumptionL, d.freshwaterConsumptionL))
return (
<div className="stats-bar-chart stats-consumption-chart" role="img" aria-label={t('stats.daily_consumption')}>
{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 (
<div key={day.entryId} className="stats-bar-column stats-bar-column--grouped">
<div className="stats-bar-group">
<div className="stats-bar-track stats-bar-track--short">
<div className="stats-bar stats-bar--fuel" style={{ height: `${fuelH}%` }} />
</div>
<div className="stats-bar-track stats-bar-track--short">
<div className="stats-bar stats-bar--water" style={{ height: `${waterH}%` }} />
</div>
</div>
<span className="stats-bar-label">{label}</span>
</div>
)
})}
<div className="stats-consumption-legend">
<span><span className="stats-legend-swatch stats-bar--fuel" /> {t('stats.fuel_legend')}</span>
<span><span className="stats-legend-swatch stats-bar--water" /> {t('stats.water_legend')}</span>
</div>
</div>
)
}
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 (
<div className="stats-propulsion">
<div className="stats-propulsion-bar" role="img" aria-label={t('stats.propulsion_title')}>
{totals.sailDistanceNm > 0 && (
<div className="stats-propulsion-segment stats-propulsion-segment--sail" style={{ width: `${sailPct}%` }} />
)}
{totals.motorDistanceNm > 0 && (
<div className="stats-propulsion-segment stats-propulsion-segment--motor" style={{ width: `${motorPct}%` }} />
)}
{totals.unknownPropulsionNm > 0 && (
<div className="stats-propulsion-segment stats-propulsion-segment--unknown" style={{ width: `${unknownPct}%` }} />
)}
</div>
<div className="stats-propulsion-labels">
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(sailPct, { maximumFractionDigits: 0 })}%)</span>
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)</span>
{totals.unknownPropulsionNm > 0 && (
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
)}
</div>
<p className="stats-hint">{t('stats.propulsion_hint')}</p>
</div>
)
}
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
if (travelDays.length === 0) {
return <div className="dashboard-status-msg">{t('stats.no_data')}</div>
}
return (
<>
<TotalsGrid totals={totals} />
{routePorts.length > 0 && (
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.route_overview')}</h3>
<p className="stats-route-chain">
{routePorts.map((port, idx) => (
<span key={`${port}-${idx}`}>
{idx > 0 && <span className="stats-route-arrow"> </span>}
{port}
</span>
))}
</p>
</div>
)}
{trackSegments.length > 0 && (
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.route_map_title')}</h3>
<MultiTrackMap segments={trackSegments} />
</div>
)}
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_etmal')}</h3>
<p className="stats-section-sub">
{t('stats.avg_distance')}: {formatNm(totals.avgDistancePerDayNm)} {t('stats.unit_nm')}
</p>
<DailyBarChart
days={travelDays}
valueFn={(d) => d.distanceNm}
barClass="stats-bar--distance"
formatValue={formatNm}
/>
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
<p className="stats-section-sub">
{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')}
</>
)}
</p>
<DailyBarChart
days={travelDays}
valueFn={(d) => d.motorHours}
barClass="stats-bar--motor-hours"
formatValue={formatHours}
/>
{travelDays.some((d) => d.fuelPerMotorHourL != null) && (
<>
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
<DailyBarChart
days={travelDays}
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
barClass="stats-bar--fuel-per-hour"
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
/>
</>
)}
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
<p className="stats-section-sub">
{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')}</>
)}
</p>
<ConsumptionChart days={travelDays} />
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
<PropulsionBreakdown totals={totals} />
</div>
{eventSeries && <EventSeriesPanel series={eventSeries} />}
</>
)
}
export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboardProps) {
const { t } = useTranslation()
const [scope, setScope] = useState<StatsScope>('logbook')
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, 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 (
<div className="form-card" data-tour="stats-dashboard">
<div className="form-header">
<BarChart2 size={24} className="form-icon" />
<div>
<h2>{t('stats.title')}</h2>
<p className="stats-subtitle">{t('stats.subtitle')}</p>
</div>
</div>
<div className="stats-scope-toggle" role="tablist" aria-label={t('stats.scope_label')}>
<button
type="button"
role="tab"
aria-selected={scope === 'logbook'}
className={`btn ${scope === 'logbook' ? 'primary' : 'secondary'}`}
onClick={() => setScope('logbook')}
>
{t('stats.scope_logbook')}
</button>
<button
type="button"
role="tab"
aria-selected={scope === 'account'}
className={`btn ${scope === 'account' ? 'primary' : 'secondary'}`}
onClick={() => setScope('account')}
>
{t('stats.scope_account')}
</button>
</div>
{error && <div className="auth-error mt-4">{error}</div>}
{loading ? (
<div className="tab-placeholder mt-6">
<BarChart2 className="header-logo spin" size={48} />
<p>{t('stats.loading')}</p>
</div>
) : scope === 'logbook' && logbookStats ? (
<LogbookScopeView summary={logbookStats} eventSeries={eventSeries} />
) : scope === 'account' && accountStats ? (
<>
<TotalsGrid totals={accountStats.totals} />
{accountLogbooksWithDays.length === 0 ? (
<div className="dashboard-status-msg mt-6">{t('stats.no_data')}</div>
) : (
<>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.account_logbooks')}</h3>
<div className="stats-account-table-wrap">
<table className="stats-account-table">
<thead>
<tr>
<th>{t('stats.col_logbook')}</th>
<th>{t('stats.travel_days')}</th>
<th>{t('stats.total_distance')}</th>
<th>{t('stats.fuel_total')}</th>
<th>{t('stats.motor_hours_total')}</th>
<th>{t('stats.water_total')}</th>
</tr>
</thead>
<tbody>
{accountLogbooksWithDays.map((lb) => (
<tr key={lb.logbookId}>
<td>{lb.title}</td>
<td>{lb.totals.travelDayCount}</td>
<td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</td>
<td>{formatLiters(lb.totals.totalFuelL)} {t('stats.unit_l')}</td>
<td>{formatHours(lb.totals.totalMotorHours)} {t('stats.unit_h')}</td>
<td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{accountStats.totals.travelDayCount > 0 && (
<>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_etmal')}</h3>
<DailyBarChart
days={allAccountDays}
valueFn={(d) => d.distanceNm}
barClass="stats-bar--distance"
formatValue={formatNm}
/>
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
{accountStats.totals.fuelPerMotorHourL != null && (
<p className="stats-section-sub">
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</p>
)}
<DailyBarChart
days={allAccountDays}
valueFn={(d) => d.motorHours}
barClass="stats-bar--motor-hours"
formatValue={formatHours}
/>
{allAccountDays.some((d) => d.fuelPerMotorHourL != null) && (
<>
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
<DailyBarChart
days={allAccountDays}
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
barClass="stats-bar--fuel-per-hour"
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
/>
</>
)}
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
{accountStats.totals.fuelPerMotorHourL != null && (
<p className="stats-section-sub">
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</p>
)}
<ConsumptionChart days={allAccountDays} />
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
<PropulsionBreakdown totals={accountStats.totals} />
</div>
</>
)}
</>
)}
</>
) : null}
</div>
)
}