From d231a7fb406ebe701773eac4f3925c6dc49897ed Mon Sep 17 00:00:00 2001 From: elpatron Date: Sat, 30 May 2026 19:29:38 +0200 Subject: [PATCH] feat(logs): Maschinenstunden pro Reisetag und Verbrauch pro Stunde Maschinenstunden sind im Journal erfassbar; der Kraftstoffverbrauch pro Maschinenstunde wird aus Tagesverbrauch und Maschinenstunden berechnet und in Journal sowie Statistik als Read-only angezeigt. Co-authored-by: Cursor --- client/src/App.css | 15 +++++ client/src/components/LogEntryEditor.tsx | 52 ++++++++++++++- client/src/components/StatsDashboard.tsx | 84 +++++++++++++++++++++++- client/src/i18n/locales/de.json | 8 +++ client/src/i18n/locales/en.json | 8 +++ client/src/services/csvExport.ts | 7 +- client/src/services/demoLogbookData.ts | 8 +++ client/src/services/statsAggregation.ts | 24 ++++++- client/src/utils/fuelStats.ts | 13 ++++ client/src/utils/logEntryPayload.ts | 4 ++ 10 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 client/src/utils/fuelStats.ts diff --git a/client/src/App.css b/client/src/App.css index 6d40766..c1a8522 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -2660,6 +2660,13 @@ html.theme-cupertino .events-scroll-container { color: var(--app-text-muted); } +.stats-section-subtitle { + margin: 0 0 12px; + font-size: 14px; + font-weight: 600; + color: var(--app-text-primary); +} + .stats-route-chain { margin: 0; font-size: 15px; @@ -2762,6 +2769,14 @@ html.theme-cupertino .events-scroll-container { background: linear-gradient(180deg, #38bdf8, #0284c7); } +.stats-bar--motor-hours { + background: linear-gradient(180deg, #a78bfa, #7c3aed); +} + +.stats-bar--fuel-per-hour { + background: linear-gradient(180deg, #fb923c, #ea580c); +} + .stats-bar-label { margin-top: 8px; font-size: 11px; diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 57c0bf0..ace98d2 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -37,6 +37,7 @@ import { type SavedTrack } from '../services/trackUpload.js' import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js' +import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js' import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx' function emptyTankLevels() { @@ -49,6 +50,7 @@ function fingerprintFromStoredEntry(decrypted: Record): string const trackDistance = decrypted.trackDistanceNm const trackSpeedMax = decrypted.trackSpeedMaxKn const trackSpeedAvg = decrypted.trackSpeedAvgKn + const motorHoursRaw = decrypted.motorHours const payload = buildLogEntryPayload({ date: String(decrypted.date || ''), @@ -79,6 +81,10 @@ function fingerprintFromStoredEntry(decrypted: Record): string trackSpeedAvg != null && trackSpeedAvg !== '' ? parseFloat(String(trackSpeedAvg)) : undefined, + motorHours: + motorHoursRaw != null && motorHoursRaw !== '' + ? parseFloat(String(motorHoursRaw)) + : undefined, events: (decrypted.events as LogEventPayload[]) || [] }) @@ -149,6 +155,9 @@ export default function LogEntryEditor({ const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('') const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('') + // Motor hours under engine propulsion (per travel day) + const [motorHours, setMotorHours] = useState('') + // Events list state const [events, setEvents] = useState([]) @@ -209,6 +218,11 @@ export default function LogEntryEditor({ if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') { setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn)) } + if (entry?.motorHours != null && entry.motorHours !== '') { + setMotorHours(String(entry.motorHours)) + } else { + setMotorHours('') + } } const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => { @@ -232,16 +246,22 @@ export default function LogEntryEditor({ trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined, trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined, trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined, + motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined, events: eventsOverride ?? events }) }, [ date, dayOfTravel, departure, destination, fwMorning, fwRefilled, fwEvening, fwConsumption, fuelMorning, fuelRefilled, fuelEvening, fuelConsumption, - trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, + trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours, events ]) + const fuelPerMotorHour = useMemo( + () => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0), + [fuelConsumption, motorHours] + ) + const currentFingerprint = useMemo(() => { const payload = buildPayloadForSigning() return JSON.stringify({ @@ -1109,6 +1129,20 @@ export default function LogEntryEditor({ disabled={saving || readOnly} /> + +
+ + setMotorHours(e.target.value)} + disabled={saving || readOnly} + min="0" + step="0.1" + placeholder="0" + /> +
@@ -1219,6 +1253,22 @@ export default function LogEntryEditor({ aria-readonly="true" /> + +
+ + +
diff --git a/client/src/components/StatsDashboard.tsx b/client/src/components/StatsDashboard.tsx index 6f36ef1..16f1ec3 100644 --- a/client/src/components/StatsDashboard.tsx +++ b/client/src/components/StatsDashboard.tsx @@ -1,9 +1,10 @@ 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 { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge, Timer } from 'lucide-react' import MultiTrackMap from './MultiTrackMap.tsx' import { formatLiters, + formatHours, formatNm, loadAccountStats, loadLogbookStats, @@ -12,6 +13,7 @@ import { type TravelDayStats } from '../services/statsAggregation.js' import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js' +import { formatFuelPerMotorHour } from '../utils/fuelStats.js' interface StatsDashboardProps { logbookId: string @@ -78,12 +80,26 @@ function TotalsGrid({ totals }: { totals: StatsTotals }) { 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')} @@ -247,6 +263,36 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) { /> +
+

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

@@ -256,6 +302,9 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) { {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')} + )}

@@ -367,6 +416,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa {t('stats.travel_days')} {t('stats.total_distance')} {t('stats.fuel_total')} + {t('stats.motor_hours_total')} {t('stats.water_total')} @@ -377,6 +427,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa {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')} ))} @@ -397,8 +448,39 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa /> +
+

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

+ )}
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 8090556..3aa36ed 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -205,6 +205,8 @@ "event_heel": "Krängung (°)", "event_sails": "Segelführung / Motor", "motor_propulsion": "Maschinenfahrt", + "motor_hours": "Maschinenstunden (gesamt)", + "fuel_per_motor_hour": "Verbrauch pro Maschinenstunde", "event_distance": "Distanz (sm)", "export_csv": "CSV herunterladen", "share_csv": "CSV teilen", @@ -479,6 +481,9 @@ "travel_days": "Reisetage", "sail_distance": "Unter Segel", "motor_distance": "Maschinenfahrt", + "motor_hours_total": "Maschinenstunden gesamt", + "daily_motor_hours": "Maschinenstunden pro Reisetag", + "avg_motor_hours": "Ø Maschinenstunden pro Reisetag", "unknown_propulsion": "Unbekannt", "fuel_total": "Kraftstoff gesamt", "water_total": "Wasser gesamt", @@ -492,9 +497,12 @@ "avg_fuel": "Ø Kraftstoff", "avg_water": "Ø Wasser", "fuel_per_nm": "Kraftstoff pro sm", + "fuel_per_motor_hour": "Kraftstoff pro Maschinenstunde", + "daily_fuel_per_motor_hour": "Kraftstoffverbrauch pro Maschinenstunde je Reisetag", "fuel_legend": "Kraftstoff", "water_legend": "Wasser", "unit_nm": "sm", + "unit_h": "h", "unit_l": "L", "day_label": "Tag {{day}}", "account_logbooks": "Logbücher im Überblick", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index ff5482a..6e00af9 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -205,6 +205,8 @@ "event_heel": "Heel Angle (°)", "event_sails": "Sails / Motor Status", "motor_propulsion": "Engine Propulsion", + "motor_hours": "Engine hours (total)", + "fuel_per_motor_hour": "Consumption per engine hour", "event_distance": "Distance (nm)", "export_csv": "Download CSV", "share_csv": "Share CSV", @@ -479,6 +481,9 @@ "travel_days": "Travel days", "sail_distance": "Under sail", "motor_distance": "Engine", + "motor_hours_total": "Total engine hours", + "daily_motor_hours": "Engine hours per travel day", + "avg_motor_hours": "Avg. engine hours per travel day", "unknown_propulsion": "Unknown", "fuel_total": "Total fuel", "water_total": "Total water", @@ -492,9 +497,12 @@ "avg_fuel": "Avg. fuel", "avg_water": "Avg. water", "fuel_per_nm": "Fuel per nm", + "fuel_per_motor_hour": "Fuel per engine hour", + "daily_fuel_per_motor_hour": "Fuel consumption per engine hour by travel day", "fuel_legend": "Fuel", "water_legend": "Water", "unit_nm": "nm", + "unit_h": "h", "unit_l": "L", "day_label": "Day {{day}}", "account_logbooks": "Logbooks overview", diff --git a/client/src/services/csvExport.ts b/client/src/services/csvExport.ts index ebfb7d6..c37fda0 100644 --- a/client/src/services/csvExport.ts +++ b/client/src/services/csvExport.ts @@ -80,7 +80,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya const headers = [ 'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'Skipper Signature', 'Crew Signature', - 'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', + 'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)', 'Event Time', 'MgK Course', 'RwK Course', 'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)', @@ -113,6 +113,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya const trackDist = entry.trackDistanceNm ?? ''; const trackMax = entry.trackSpeedMaxKn ?? ''; const trackAvg = entry.trackSpeedAvgKn ?? ''; + const motorH = entry.motorHours ?? ''; const fwM = entry.freshwater?.morning ?? ''; const fwR = entry.freshwater?.refilled ?? ''; const fwE = entry.freshwater?.evening ?? ''; @@ -128,7 +129,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya rows.push([ dateVal, travelDay, dep, dest, signS, signC, - trackDist, trackMax, trackAvg, + trackDist, trackMax, trackAvg, motorH, '', '', '', '', '', '', '', '', '', '', '', '', @@ -144,7 +145,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya rows.push([ dateVal, travelDay, dep, dest, signS, signC, - trackDist, trackMax, trackAvg, + trackDist, trackMax, trackAvg, motorH, ev.time || '', ev.mgk || '', ev.rwk || '', ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '', ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '', diff --git a/client/src/services/demoLogbookData.ts b/client/src/services/demoLogbookData.ts index fa67ec1..52001d1 100644 --- a/client/src/services/demoLogbookData.ts +++ b/client/src/services/demoLogbookData.ts @@ -26,6 +26,7 @@ export interface DemoDaySpec { filename: string freshwater: { morning: number; refilled: number; evening: number; consumption: number } fuel: { morning: number; refilled: number; evening: number; consumption: number } + motorHours?: number events: Array> } @@ -100,6 +101,7 @@ export function buildDemoDays(): DemoDaySpec[] { filename: 'laboe-damp.gpx', freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 }, fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 }, + motorHours: 1.5, events: [ { time: '09:00', @@ -247,6 +249,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture { entryPayload.trackSpeedMaxKn = stats.speedMaxKn entryPayload.trackSpeedAvgKn = stats.speedAvgKn } + if (day.motorHours != null && day.motorHours > 0) { + entryPayload.motorHours = day.motorHours + } entries.push(entryPayload as PublicDemoFixture['entries'][number]) @@ -303,6 +308,9 @@ export function buildDemoEntryPayloads(): Array<{ entryPayload.trackSpeedMaxKn = stats.speedMaxKn entryPayload.trackSpeedAvgKn = stats.speedAvgKn } + if (day.motorHours != null && day.motorHours > 0) { + entryPayload.motorHours = day.motorHours + } return { entryId, diff --git a/client/src/services/statsAggregation.ts b/client/src/services/statsAggregation.ts index 60b4eb4..24fffc4 100644 --- a/client/src/services/statsAggregation.ts +++ b/client/src/services/statsAggregation.ts @@ -10,6 +10,7 @@ import { parseEventDistanceNm, splitDistanceByPropulsion } from '../utils/propulsionStats.js' +import { computeFuelPerMotorHour } from '../utils/fuelStats.js' export type DistanceSource = 'gps' | 'events' | 'none' @@ -27,6 +28,8 @@ export interface TravelDayStats { sailDistanceNm: number motorDistanceNm: number unknownPropulsionNm: number + motorHours: number + fuelPerMotorHourL: number | null hasGpsTrack: boolean } @@ -59,12 +62,15 @@ export interface StatsTotals { sailDistanceNm: number motorDistanceNm: number unknownPropulsionNm: number + totalMotorHours: number totalFuelL: number totalFreshwaterL: number avgDistancePerDayNm: number + avgMotorHoursPerDay: number avgFuelPerDayL: number avgFreshwaterPerDayL: number fuelPerNmL: number | null + fuelPerMotorHourL: number | null } const TRACK_COLORS = [ @@ -102,6 +108,7 @@ function buildTotals(days: TravelDayStats[]): StatsTotals { 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 totalMotorHours = days.reduce((sum, d) => sum + d.motorHours, 0) const totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 0) const totalFreshwaterL = days.reduce((sum, d) => sum + d.freshwaterConsumptionL, 0) @@ -112,10 +119,13 @@ function buildTotals(days: TravelDayStats[]): StatsTotals { sailDistanceNm: Number(sailDistanceNm.toFixed(2)), motorDistanceNm: Number(motorDistanceNm.toFixed(2)), unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)), + totalMotorHours: Number(totalMotorHours.toFixed(1)), totalFuelL: Number(totalFuelL.toFixed(1)), totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)), avgDistancePerDayNm: travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0, + avgMotorHoursPerDay: + travelDayCount > 0 ? Number((totalMotorHours / travelDayCount).toFixed(1)) : 0, avgFuelPerDayL: travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0, avgFreshwaterPerDayL: @@ -123,7 +133,8 @@ function buildTotals(days: TravelDayStats[]): StatsTotals { fuelPerNmL: totalDistanceNm > 0 && totalFuelL > 0 ? Number((totalFuelL / totalDistanceNm).toFixed(2)) - : null + : null, + fuelPerMotorHourL: computeFuelPerMotorHour(totalFuelL, totalMotorHours) } } @@ -180,6 +191,9 @@ async function loadTravelDaysForLogbook( hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId)) } + const fuelConsumptionL = Number(payload.fuel?.consumption) || 0 + const motorHours = Number(payload.motorHours) || 0 + days.push({ entryId: entry.payloadId, logbookId, @@ -189,11 +203,13 @@ async function loadTravelDaysForLogbook( destination: payload.destination || '', distanceNm, distanceSource, - fuelConsumptionL: Number(payload.fuel?.consumption) || 0, + fuelConsumptionL, freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0, sailDistanceNm: propulsion.sailDistanceNm, motorDistanceNm: propulsion.motorDistanceNm, unknownPropulsionNm: propulsion.unknownPropulsionNm, + motorHours, + fuelPerMotorHourL: computeFuelPerMotorHour(fuelConsumptionL, motorHours), hasGpsTrack }) } @@ -249,3 +265,7 @@ export function formatNm(value: number): string { export function formatLiters(value: number): string { return Number.isInteger(value) ? String(value) : value.toFixed(1) } + +export function formatHours(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(1) +} diff --git a/client/src/utils/fuelStats.ts b/client/src/utils/fuelStats.ts new file mode 100644 index 0000000..55bd0be --- /dev/null +++ b/client/src/utils/fuelStats.ts @@ -0,0 +1,13 @@ +/** Liters per motor hour from daily fuel consumption and motor hours. */ +export function computeFuelPerMotorHour( + fuelConsumptionL: number, + motorHours: number +): number | null { + if (motorHours <= 0) return null + return Number((fuelConsumptionL / motorHours).toFixed(2)) +} + +export function formatFuelPerMotorHour(value: number | null | undefined): string { + if (value == null) return '—' + return Number.isInteger(value) ? String(value) : value.toFixed(2) +} diff --git a/client/src/utils/logEntryPayload.ts b/client/src/utils/logEntryPayload.ts index c8e4498..abcee5a 100644 --- a/client/src/utils/logEntryPayload.ts +++ b/client/src/utils/logEntryPayload.ts @@ -71,6 +71,7 @@ export interface LogEntryPayloadInput { trackDistanceNm?: number trackSpeedMaxKn?: number trackSpeedAvgKn?: number + motorHours?: number events: LogEventPayload[] } @@ -88,6 +89,9 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record 0) { + payload.motorHours = Number(input.motorHours.toFixed(2)) + } return payload }