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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-30 19:29:38 +02:00
co-authored by Cursor
parent 4acb9b1290
commit d231a7fb40
10 changed files with 216 additions and 7 deletions
+15
View File
@@ -2660,6 +2660,13 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-text-muted); 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 { .stats-route-chain {
margin: 0; margin: 0;
font-size: 15px; font-size: 15px;
@@ -2762,6 +2769,14 @@ html.theme-cupertino .events-scroll-container {
background: linear-gradient(180deg, #38bdf8, #0284c7); 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 { .stats-bar-label {
margin-top: 8px; margin-top: 8px;
font-size: 11px; font-size: 11px;
+51 -1
View File
@@ -37,6 +37,7 @@ import {
type SavedTrack type SavedTrack
} from '../services/trackUpload.js' } from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js' import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx' import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
function emptyTankLevels() { function emptyTankLevels() {
@@ -49,6 +50,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
const trackDistance = decrypted.trackDistanceNm const trackDistance = decrypted.trackDistanceNm
const trackSpeedMax = decrypted.trackSpeedMaxKn const trackSpeedMax = decrypted.trackSpeedMaxKn
const trackSpeedAvg = decrypted.trackSpeedAvgKn const trackSpeedAvg = decrypted.trackSpeedAvgKn
const motorHoursRaw = decrypted.motorHours
const payload = buildLogEntryPayload({ const payload = buildLogEntryPayload({
date: String(decrypted.date || ''), date: String(decrypted.date || ''),
@@ -79,6 +81,10 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
trackSpeedAvg != null && trackSpeedAvg !== '' trackSpeedAvg != null && trackSpeedAvg !== ''
? parseFloat(String(trackSpeedAvg)) ? parseFloat(String(trackSpeedAvg))
: undefined, : undefined,
motorHours:
motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw))
: undefined,
events: (decrypted.events as LogEventPayload[]) || [] events: (decrypted.events as LogEventPayload[]) || []
}) })
@@ -149,6 +155,9 @@ export default function LogEntryEditor({
const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('') const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('')
const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('') const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('')
// Motor hours under engine propulsion (per travel day)
const [motorHours, setMotorHours] = useState('')
// Events list state // Events list state
const [events, setEvents] = useState<LogEvent[]>([]) const [events, setEvents] = useState<LogEvent[]>([])
@@ -209,6 +218,11 @@ export default function LogEntryEditor({
if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') { if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') {
setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn)) setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn))
} }
if (entry?.motorHours != null && entry.motorHours !== '') {
setMotorHours(String(entry.motorHours))
} else {
setMotorHours('')
}
} }
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => { const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
@@ -232,16 +246,22 @@ export default function LogEntryEditor({
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined, trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined, trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined, trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
events: eventsOverride ?? events events: eventsOverride ?? events
}) })
}, [ }, [
date, dayOfTravel, departure, destination, date, dayOfTravel, departure, destination,
fwMorning, fwRefilled, fwEvening, fwConsumption, fwMorning, fwRefilled, fwEvening, fwConsumption,
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption, fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
events events
]) ])
const fuelPerMotorHour = useMemo(
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
[fuelConsumption, motorHours]
)
const currentFingerprint = useMemo(() => { const currentFingerprint = useMemo(() => {
const payload = buildPayloadForSigning() const payload = buildPayloadForSigning()
return JSON.stringify({ return JSON.stringify({
@@ -1109,6 +1129,20 @@ export default function LogEntryEditor({
disabled={saving || readOnly} disabled={saving || readOnly}
/> />
</div> </div>
<div className="input-group">
<label>{t('logs.motor_hours')}</label>
<input
type="number"
className="input-text"
value={motorHours}
onChange={(e) => setMotorHours(e.target.value)}
disabled={saving || readOnly}
min="0"
step="0.1"
placeholder="0"
/>
</div>
</div> </div>
</div> </div>
@@ -1219,6 +1253,22 @@ export default function LogEntryEditor({
aria-readonly="true" aria-readonly="true"
/> />
</div> </div>
<div className="input-group">
<label>{t('logs.fuel_per_motor_hour')}</label>
<input
type="text"
className="input-text consumption-value"
value={
fuelPerMotorHour != null
? `${formatFuelPerMotorHour(fuelPerMotorHour)} L/h`
: '—'
}
readOnly
tabIndex={-1}
aria-readonly="true"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
+83 -1
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' 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 MultiTrackMap from './MultiTrackMap.tsx'
import { import {
formatLiters, formatLiters,
formatHours,
formatNm, formatNm,
loadAccountStats, loadAccountStats,
loadLogbookStats, loadLogbookStats,
@@ -12,6 +13,7 @@ import {
type TravelDayStats type TravelDayStats
} from '../services/statsAggregation.js' } from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js' import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
interface StatsDashboardProps { interface StatsDashboardProps {
logbookId: string logbookId: string
@@ -78,12 +80,26 @@ function TotalsGrid({ totals }: { totals: StatsTotals }) {
value={formatNm(totals.motorDistanceNm)} value={formatNm(totals.motorDistanceNm)}
unit={t('stats.unit_nm')} 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 <KpiCard
icon={<Fuel size={20} />} icon={<Fuel size={20} />}
label={t('stats.fuel_total')} label={t('stats.fuel_total')}
value={formatLiters(totals.totalFuelL)} value={formatLiters(totals.totalFuelL)}
unit={t('stats.unit_l')} 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 <KpiCard
icon={<Droplets size={20} />} icon={<Droplets size={20} />}
label={t('stats.water_total')} label={t('stats.water_total')}
@@ -247,6 +263,36 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
/> />
</div> </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"> <div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3> <h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
<p className="stats-section-sub"> <p className="stats-section-sub">
@@ -256,6 +302,9 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
{totals.fuelPerNmL != null && ( {totals.fuelPerNmL != null && (
<> · {t('stats.fuel_per_nm')}: {totals.fuelPerNmL} {t('stats.unit_l')}/{t('stats.unit_nm')}</> <> · {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> </p>
<ConsumptionChart days={travelDays} /> <ConsumptionChart days={travelDays} />
</div> </div>
@@ -367,6 +416,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<th>{t('stats.travel_days')}</th> <th>{t('stats.travel_days')}</th>
<th>{t('stats.total_distance')}</th> <th>{t('stats.total_distance')}</th>
<th>{t('stats.fuel_total')}</th> <th>{t('stats.fuel_total')}</th>
<th>{t('stats.motor_hours_total')}</th>
<th>{t('stats.water_total')}</th> <th>{t('stats.water_total')}</th>
</tr> </tr>
</thead> </thead>
@@ -377,6 +427,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<td>{lb.totals.travelDayCount}</td> <td>{lb.totals.travelDayCount}</td>
<td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</td> <td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</td>
<td>{formatLiters(lb.totals.totalFuelL)} {t('stats.unit_l')}</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> <td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td>
</tr> </tr>
))} ))}
@@ -397,8 +448,39 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
/> />
</div> </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"> <div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3> <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} /> <ConsumptionChart days={allAccountDays} />
</div> </div>
+8
View File
@@ -205,6 +205,8 @@
"event_heel": "Krängung (°)", "event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor", "event_sails": "Segelführung / Motor",
"motor_propulsion": "Maschinenfahrt", "motor_propulsion": "Maschinenfahrt",
"motor_hours": "Maschinenstunden (gesamt)",
"fuel_per_motor_hour": "Verbrauch pro Maschinenstunde",
"event_distance": "Distanz (sm)", "event_distance": "Distanz (sm)",
"export_csv": "CSV herunterladen", "export_csv": "CSV herunterladen",
"share_csv": "CSV teilen", "share_csv": "CSV teilen",
@@ -479,6 +481,9 @@
"travel_days": "Reisetage", "travel_days": "Reisetage",
"sail_distance": "Unter Segel", "sail_distance": "Unter Segel",
"motor_distance": "Maschinenfahrt", "motor_distance": "Maschinenfahrt",
"motor_hours_total": "Maschinenstunden gesamt",
"daily_motor_hours": "Maschinenstunden pro Reisetag",
"avg_motor_hours": "Ø Maschinenstunden pro Reisetag",
"unknown_propulsion": "Unbekannt", "unknown_propulsion": "Unbekannt",
"fuel_total": "Kraftstoff gesamt", "fuel_total": "Kraftstoff gesamt",
"water_total": "Wasser gesamt", "water_total": "Wasser gesamt",
@@ -492,9 +497,12 @@
"avg_fuel": "Ø Kraftstoff", "avg_fuel": "Ø Kraftstoff",
"avg_water": "Ø Wasser", "avg_water": "Ø Wasser",
"fuel_per_nm": "Kraftstoff pro sm", "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", "fuel_legend": "Kraftstoff",
"water_legend": "Wasser", "water_legend": "Wasser",
"unit_nm": "sm", "unit_nm": "sm",
"unit_h": "h",
"unit_l": "L", "unit_l": "L",
"day_label": "Tag {{day}}", "day_label": "Tag {{day}}",
"account_logbooks": "Logbücher im Überblick", "account_logbooks": "Logbücher im Überblick",
+8
View File
@@ -205,6 +205,8 @@
"event_heel": "Heel Angle (°)", "event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status", "event_sails": "Sails / Motor Status",
"motor_propulsion": "Engine Propulsion", "motor_propulsion": "Engine Propulsion",
"motor_hours": "Engine hours (total)",
"fuel_per_motor_hour": "Consumption per engine hour",
"event_distance": "Distance (nm)", "event_distance": "Distance (nm)",
"export_csv": "Download CSV", "export_csv": "Download CSV",
"share_csv": "Share CSV", "share_csv": "Share CSV",
@@ -479,6 +481,9 @@
"travel_days": "Travel days", "travel_days": "Travel days",
"sail_distance": "Under sail", "sail_distance": "Under sail",
"motor_distance": "Engine", "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", "unknown_propulsion": "Unknown",
"fuel_total": "Total fuel", "fuel_total": "Total fuel",
"water_total": "Total water", "water_total": "Total water",
@@ -492,9 +497,12 @@
"avg_fuel": "Avg. fuel", "avg_fuel": "Avg. fuel",
"avg_water": "Avg. water", "avg_water": "Avg. water",
"fuel_per_nm": "Fuel per nm", "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", "fuel_legend": "Fuel",
"water_legend": "Water", "water_legend": "Water",
"unit_nm": "nm", "unit_nm": "nm",
"unit_h": "h",
"unit_l": "L", "unit_l": "L",
"day_label": "Day {{day}}", "day_label": "Day {{day}}",
"account_logbooks": "Logbooks overview", "account_logbooks": "Logbooks overview",
+4 -3
View File
@@ -80,7 +80,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const headers = [ const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
'Skipper Signature', 'Crew Signature', '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', 'Event Time', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)', '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 trackDist = entry.trackDistanceNm ?? '';
const trackMax = entry.trackSpeedMaxKn ?? ''; const trackMax = entry.trackSpeedMaxKn ?? '';
const trackAvg = entry.trackSpeedAvgKn ?? ''; const trackAvg = entry.trackSpeedAvgKn ?? '';
const motorH = entry.motorHours ?? '';
const fwM = entry.freshwater?.morning ?? ''; const fwM = entry.freshwater?.morning ?? '';
const fwR = entry.freshwater?.refilled ?? ''; const fwR = entry.freshwater?.refilled ?? '';
const fwE = entry.freshwater?.evening ?? ''; const fwE = entry.freshwater?.evening ?? '';
@@ -128,7 +129,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, trackDist, trackMax, trackAvg, motorH,
'', '', '', '', '', '',
'', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
@@ -144,7 +145,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '', ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '', ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '', ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
+8
View File
@@ -26,6 +26,7 @@ export interface DemoDaySpec {
filename: string filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number } freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number } fuel: { morning: number; refilled: number; evening: number; consumption: number }
motorHours?: number
events: Array<Record<string, string>> events: Array<Record<string, string>>
} }
@@ -100,6 +101,7 @@ export function buildDemoDays(): DemoDaySpec[] {
filename: 'laboe-damp.gpx', filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 }, freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 }, fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
motorHours: 1.5,
events: [ events: [
{ {
time: '09:00', time: '09:00',
@@ -247,6 +249,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
entryPayload.trackSpeedMaxKn = stats.speedMaxKn entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn entryPayload.trackSpeedAvgKn = stats.speedAvgKn
} }
if (day.motorHours != null && day.motorHours > 0) {
entryPayload.motorHours = day.motorHours
}
entries.push(entryPayload as PublicDemoFixture['entries'][number]) entries.push(entryPayload as PublicDemoFixture['entries'][number])
@@ -303,6 +308,9 @@ export function buildDemoEntryPayloads(): Array<{
entryPayload.trackSpeedMaxKn = stats.speedMaxKn entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn entryPayload.trackSpeedAvgKn = stats.speedAvgKn
} }
if (day.motorHours != null && day.motorHours > 0) {
entryPayload.motorHours = day.motorHours
}
return { return {
entryId, entryId,
+22 -2
View File
@@ -10,6 +10,7 @@ import {
parseEventDistanceNm, parseEventDistanceNm,
splitDistanceByPropulsion splitDistanceByPropulsion
} from '../utils/propulsionStats.js' } from '../utils/propulsionStats.js'
import { computeFuelPerMotorHour } from '../utils/fuelStats.js'
export type DistanceSource = 'gps' | 'events' | 'none' export type DistanceSource = 'gps' | 'events' | 'none'
@@ -27,6 +28,8 @@ export interface TravelDayStats {
sailDistanceNm: number sailDistanceNm: number
motorDistanceNm: number motorDistanceNm: number
unknownPropulsionNm: number unknownPropulsionNm: number
motorHours: number
fuelPerMotorHourL: number | null
hasGpsTrack: boolean hasGpsTrack: boolean
} }
@@ -59,12 +62,15 @@ export interface StatsTotals {
sailDistanceNm: number sailDistanceNm: number
motorDistanceNm: number motorDistanceNm: number
unknownPropulsionNm: number unknownPropulsionNm: number
totalMotorHours: number
totalFuelL: number totalFuelL: number
totalFreshwaterL: number totalFreshwaterL: number
avgDistancePerDayNm: number avgDistancePerDayNm: number
avgMotorHoursPerDay: number
avgFuelPerDayL: number avgFuelPerDayL: number
avgFreshwaterPerDayL: number avgFreshwaterPerDayL: number
fuelPerNmL: number | null fuelPerNmL: number | null
fuelPerMotorHourL: number | null
} }
const TRACK_COLORS = [ const TRACK_COLORS = [
@@ -102,6 +108,7 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0) const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0)
const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0) const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0)
const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 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 totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 0)
const totalFreshwaterL = days.reduce((sum, d) => sum + d.freshwaterConsumptionL, 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)), sailDistanceNm: Number(sailDistanceNm.toFixed(2)),
motorDistanceNm: Number(motorDistanceNm.toFixed(2)), motorDistanceNm: Number(motorDistanceNm.toFixed(2)),
unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)), unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)),
totalMotorHours: Number(totalMotorHours.toFixed(1)),
totalFuelL: Number(totalFuelL.toFixed(1)), totalFuelL: Number(totalFuelL.toFixed(1)),
totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)), totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)),
avgDistancePerDayNm: avgDistancePerDayNm:
travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0, travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0,
avgMotorHoursPerDay:
travelDayCount > 0 ? Number((totalMotorHours / travelDayCount).toFixed(1)) : 0,
avgFuelPerDayL: avgFuelPerDayL:
travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0, travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0,
avgFreshwaterPerDayL: avgFreshwaterPerDayL:
@@ -123,7 +133,8 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
fuelPerNmL: fuelPerNmL:
totalDistanceNm > 0 && totalFuelL > 0 totalDistanceNm > 0 && totalFuelL > 0
? Number((totalFuelL / totalDistanceNm).toFixed(2)) ? 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)) hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId))
} }
const fuelConsumptionL = Number(payload.fuel?.consumption) || 0
const motorHours = Number(payload.motorHours) || 0
days.push({ days.push({
entryId: entry.payloadId, entryId: entry.payloadId,
logbookId, logbookId,
@@ -189,11 +203,13 @@ async function loadTravelDaysForLogbook(
destination: payload.destination || '', destination: payload.destination || '',
distanceNm, distanceNm,
distanceSource, distanceSource,
fuelConsumptionL: Number(payload.fuel?.consumption) || 0, fuelConsumptionL,
freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0, freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0,
sailDistanceNm: propulsion.sailDistanceNm, sailDistanceNm: propulsion.sailDistanceNm,
motorDistanceNm: propulsion.motorDistanceNm, motorDistanceNm: propulsion.motorDistanceNm,
unknownPropulsionNm: propulsion.unknownPropulsionNm, unknownPropulsionNm: propulsion.unknownPropulsionNm,
motorHours,
fuelPerMotorHourL: computeFuelPerMotorHour(fuelConsumptionL, motorHours),
hasGpsTrack hasGpsTrack
}) })
} }
@@ -249,3 +265,7 @@ export function formatNm(value: number): string {
export function formatLiters(value: number): string { export function formatLiters(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1) return Number.isInteger(value) ? String(value) : value.toFixed(1)
} }
export function formatHours(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
+13
View File
@@ -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)
}
+4
View File
@@ -71,6 +71,7 @@ export interface LogEntryPayloadInput {
trackDistanceNm?: number trackDistanceNm?: number
trackSpeedMaxKn?: number trackSpeedMaxKn?: number
trackSpeedAvgKn?: number trackSpeedAvgKn?: number
motorHours?: number
events: LogEventPayload[] events: LogEventPayload[]
} }
@@ -88,6 +89,9 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn
if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn
if (input.motorHours !== undefined && input.motorHours > 0) {
payload.motorHours = Number(input.motorHours.toFixed(2))
}
return payload return payload
} }