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:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user