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:
@@ -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, unknown>): 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, unknown>): 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<LogEvent[]>([])
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -1219,6 +1253,22 @@ export default function LogEntryEditor({
|
||||
aria-readonly="true"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
<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')}
|
||||
@@ -247,6 +263,36 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
||||
/>
|
||||
</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">
|
||||
@@ -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')}</>
|
||||
)}
|
||||
</p>
|
||||
<ConsumptionChart days={travelDays} />
|
||||
</div>
|
||||
@@ -367,6 +416,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
||||
<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>
|
||||
@@ -377,6 +427,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
||||
<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>
|
||||
))}
|
||||
@@ -397,8 +448,39 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
||||
/>
|
||||
</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user