diff --git a/client/src/App.css b/client/src/App.css index f02d757..f1c8cca 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -2109,6 +2109,325 @@ html.theme-cupertino .events-scroll-container { background: rgba(255, 255, 255, 0.8); } +/* --- Statistics dashboard --- */ +.stats-subtitle { + margin: 4px 0 0; + font-size: 14px; + color: var(--app-text-muted); + font-weight: 400; +} + +.stats-scope-toggle { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 20px; +} + +.stats-scope-toggle .btn { + width: auto; + padding: 10px 18px; +} + +.stats-kpi-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin-top: 24px; +} + +.stats-kpi-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px; + border-radius: var(--app-radius-card); + border: 1px solid var(--app-border-subtle); +} + +.stats-kpi-icon { + color: var(--app-accent-light); + flex-shrink: 0; + margin-top: 2px; +} + +.stats-kpi-label { + display: block; + font-size: 12px; + color: var(--app-text-muted); + margin-bottom: 4px; +} + +.stats-kpi-value { + font-size: 22px; + font-weight: 700; + line-height: 1.2; + color: var(--app-text-primary); +} + +.stats-kpi-unit { + font-size: 13px; + font-weight: 500; + color: var(--app-text-muted); + margin-left: 4px; +} + +.stats-section-title { + margin: 0 0 8px; + font-size: 17px; + font-weight: 600; +} + +.stats-section-sub { + margin: 0 0 16px; + font-size: 13px; + color: var(--app-text-muted); +} + +.stats-route-chain { + margin: 0; + font-size: 15px; + line-height: 1.6; + color: var(--app-text-primary); +} + +.stats-route-arrow { + color: var(--app-accent-light); + font-weight: 600; +} + +.stats-multi-track-map { + min-height: 320px; +} + +.stats-track-legend { + display: flex; + flex-wrap: wrap; + gap: 12px 18px; + margin-top: 12px; + font-size: 13px; + color: var(--app-text-muted); +} + +.stats-track-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.stats-track-legend-swatch { + width: 14px; + height: 14px; + border-radius: 3px; + flex-shrink: 0; +} + +.stats-bar-chart { + display: flex; + align-items: flex-end; + gap: 8px; + min-height: 180px; + padding-top: 8px; + overflow-x: auto; +} + +.stats-bar-column { + display: flex; + flex-direction: column; + align-items: center; + min-width: 52px; + flex: 1 0 52px; +} + +.stats-bar-column--grouped { + min-width: 44px; +} + +.stats-bar-value { + font-size: 10px; + color: var(--app-text-muted); + min-height: 14px; + margin-bottom: 4px; +} + +.stats-bar-track { + width: 100%; + max-width: 36px; + height: 120px; + display: flex; + align-items: flex-end; + justify-content: center; + background: var(--app-surface-muted); + border-radius: 6px 6px 2px 2px; + overflow: hidden; +} + +.stats-bar-track--short { + max-width: 14px; + height: 100px; +} + +.stats-bar { + width: 100%; + border-radius: 4px 4px 0 0; + min-height: 2px; + transition: height 0.2s ease; +} + +.stats-bar--distance { + background: linear-gradient(180deg, var(--app-accent-light), var(--app-accent)); +} + +.stats-bar--fuel { + background: linear-gradient(180deg, #fbbf24, #d97706); +} + +.stats-bar--water { + background: linear-gradient(180deg, #38bdf8, #0284c7); +} + +.stats-bar-label { + margin-top: 8px; + font-size: 11px; + color: var(--app-text-muted); + text-align: center; +} + +.stats-bar-sublabel { + font-size: 10px; + color: var(--app-text-muted); + opacity: 0.85; +} + +.stats-bar-group { + display: flex; + gap: 4px; + align-items: flex-end; + height: 100px; +} + +.stats-consumption-chart { + flex-direction: column; + align-items: stretch; + min-height: auto; +} + +.stats-consumption-chart .stats-bar-column--grouped { + display: inline-flex; + vertical-align: bottom; +} + +.stats-consumption-chart { + display: block; + overflow-x: auto; + white-space: nowrap; + padding-bottom: 8px; +} + +.stats-consumption-chart .stats-bar-column--grouped { + display: inline-flex; + white-space: normal; +} + +.stats-consumption-legend { + display: flex; + gap: 20px; + margin-top: 16px; + font-size: 13px; + color: var(--app-text-muted); +} + +.stats-consumption-legend span { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.stats-legend-swatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 2px; +} + +.stats-propulsion-bar { + display: flex; + width: 100%; + height: 18px; + border-radius: 999px; + overflow: hidden; + background: var(--app-surface-muted); +} + +.stats-propulsion-segment--sail { + background: var(--app-accent); +} + +.stats-propulsion-segment--motor { + background: #d97706; +} + +.stats-propulsion-segment--unknown { + background: var(--app-text-muted); + opacity: 0.45; +} + +.stats-propulsion-labels { + display: flex; + flex-wrap: wrap; + gap: 12px 20px; + margin-top: 12px; + font-size: 13px; + color: var(--app-text-primary); +} + +.stats-hint { + margin: 12px 0 0; + font-size: 12px; + color: var(--app-text-muted); +} + +.stats-account-table-wrap { + overflow-x: auto; +} + +.stats-account-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.stats-account-table th, +.stats-account-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--app-border-subtle); +} + +.stats-account-table th { + font-size: 12px; + font-weight: 600; + color: var(--app-text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +@media (max-width: 1024px) { + .stats-kpi-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .stats-kpi-grid { + grid-template-columns: 1fr; + } + + .stats-kpi-value { + font-size: 20px; + } +} + .signature-grid { align-items: start; } diff --git a/client/src/App.tsx b/client/src/App.tsx index 4af98fc..61f8b11 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -8,6 +8,7 @@ import CrewForm from './components/CrewForm.tsx' // Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten) // import DeviationForm from './components/DeviationForm.tsx' import LogEntriesList from './components/LogEntriesList.tsx' +import StatsDashboard from './components/StatsDashboard.tsx' import SettingsForm from './components/SettingsForm.tsx' import InvitationAcceptance from './components/InvitationAcceptance.tsx' import AppTourOverlay from './components/AppTourOverlay.tsx' @@ -27,7 +28,7 @@ import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx' import AppFooter from './components/AppFooter.tsx' import { db } from './services/db.js' import { useLiveQuery } from 'dexie-react-hooks' -import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages } from 'lucide-react' +import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react' import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx' import { useTranslation } from 'react-i18next' import { @@ -337,6 +338,14 @@ function App() { */} + + + + + + {error &&
{error}
} + + {loading ? ( +
+ +

{t('stats.loading')}

+
+ ) : scope === 'logbook' && logbookStats ? ( + + ) : scope === 'account' && accountStats ? ( + <> + + + {accountLogbooksWithDays.length === 0 ? ( +
{t('stats.no_data')}
+ ) : ( + <> +
+

{t('stats.account_logbooks')}

+
+ + + + + + + + + + + + {accountLogbooksWithDays.map((lb) => ( + + + + + + + + ))} + +
{t('stats.col_logbook')}{t('stats.travel_days')}{t('stats.total_distance')}{t('stats.fuel_total')}{t('stats.water_total')}
{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 +}