Files
kapteins-daagbok/client/src/services/statsAggregation.ts
T
elpatron 3cab735754 refactor: replace parseFloat with parseAppDecimal and formatAppDecimal for improved number handling
Updated various components to utilize parseAppDecimal and formatAppDecimal for consistent decimal parsing and formatting. This change enhances the handling of numeric inputs across the application, ensuring better accuracy and user experience in forms and displays.
2026-06-03 18:07:22 +02:00

262 lines
8.2 KiB
TypeScript

import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { decryptLogbookTitle } from './logbook.js'
import { getDecryptedTrack, type TrackWaypoint } from './trackUpload.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import type { LogEntryPayloadInput } from '../utils/logEntryPayload.js'
import {
parseEventDistanceNm,
splitDistanceByPropulsion
} from '../utils/propulsionStats.js'
import { computeFuelPerMotorHour } from '../utils/fuelStats.js'
export type DistanceSource = 'gps' | 'events' | 'none'
export interface TravelDayStats {
entryId: string
logbookId: string
date: string
dayOfTravel: string
departure: string
destination: string
distanceNm: number
distanceSource: DistanceSource
fuelConsumptionL: number
freshwaterConsumptionL: number
sailDistanceNm: number
motorDistanceNm: number
unknownPropulsionNm: number
motorHours: number
fuelPerMotorHourL: number | null
hasGpsTrack: boolean
}
export interface TrackSegment {
entryId: string
dayOfTravel: string
label: string
waypoints: TrackWaypoint[]
colorIndex: number
}
export interface LogbookStatsSummary {
logbookId: string
title: string
travelDays: TravelDayStats[]
routePorts: string[]
trackSegments: TrackSegment[]
totals: StatsTotals
}
export interface AccountStatsSummary {
logbooks: LogbookStatsSummary[]
totals: StatsTotals
}
export interface StatsTotals {
travelDayCount: number
daysWithGps: number
totalDistanceNm: number
sailDistanceNm: number
motorDistanceNm: number
unknownPropulsionNm: number
totalMotorHours: number
totalFuelL: number
totalFreshwaterL: number
avgDistancePerDayNm: number
avgMotorHoursPerDay: number
avgFuelPerDayL: number
avgFreshwaterPerDayL: number
fuelPerNmL: number | null
fuelPerMotorHourL: number | null
}
const TRACK_COLORS = [
'#3b82f6',
'#10b981',
'#f59e0b',
'#8b5cf6',
'#ec4899',
'#06b6d4',
'#ef4444',
'#84cc16'
]
function resolveDistanceNm(payload: LogEntryPayloadInput): { distanceNm: number; distanceSource: DistanceSource } {
const gpsDistance = Number(payload.trackDistanceNm) || 0
if (gpsDistance > 0) {
return { distanceNm: gpsDistance, distanceSource: 'gps' }
}
const eventSum = (payload.events || []).reduce(
(sum, event) => sum + parseEventDistanceNm(event.distance),
0
)
if (eventSum > 0) {
return { distanceNm: Number(eventSum.toFixed(2)), distanceSource: 'events' }
}
return { distanceNm: 0, distanceSource: 'none' }
}
function buildTotals(days: TravelDayStats[]): StatsTotals {
const travelDayCount = days.length
const daysWithGps = days.filter((d) => d.hasGpsTrack).length
const totalDistanceNm = days.reduce((sum, d) => sum + d.distanceNm, 0)
const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0)
const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0)
const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 0)
const 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)
return {
travelDayCount,
daysWithGps,
totalDistanceNm: Number(totalDistanceNm.toFixed(2)),
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:
travelDayCount > 0 ? Number((totalFreshwaterL / travelDayCount).toFixed(1)) : 0,
fuelPerNmL:
totalDistanceNm > 0 && totalFuelL > 0
? Number((totalFuelL / totalDistanceNm).toFixed(2))
: null,
fuelPerMotorHourL: computeFuelPerMotorHour(totalFuelL, totalMotorHours)
}
}
export function buildRoutePorts(days: TravelDayStats[]): string[] {
const ports: string[] = []
for (const day of days) {
const dep = day.departure.trim()
const dest = day.destination.trim()
if (dep && (ports.length === 0 || ports[ports.length - 1] !== dep)) {
ports.push(dep)
}
if (dest && (ports.length === 0 || ports[ports.length - 1] !== dest)) {
ports.push(dest)
}
}
return ports
}
async function loadTravelDaysForLogbook(
logbookId: string,
includeTracks: boolean
): Promise<{ days: TravelDayStats[]; trackSegments: TrackSegment[] }> {
const masterKey = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!masterKey) {
throw new Error('Encryption key not found. Please log in.')
}
const localEntries = await db.entries.where({ logbookId }).toArray()
const days: TravelDayStats[] = []
const trackSegments: TrackSegment[] = []
for (const entry of localEntries) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (!decrypted) continue
const payload = decrypted as LogEntryPayloadInput
const { distanceNm, distanceSource } = resolveDistanceNm(payload)
const propulsion = splitDistanceByPropulsion(distanceNm, payload.events || [])
let hasGpsTrack = false
if (includeTracks) {
const track = await getDecryptedTrack(entry.payloadId)
if (track && track.waypoints.length >= 2) {
hasGpsTrack = true
trackSegments.push({
entryId: entry.payloadId,
dayOfTravel: payload.dayOfTravel || '',
label: payload.dayOfTravel || '?',
waypoints: track.waypoints,
colorIndex: trackSegments.length % TRACK_COLORS.length
})
}
} else {
hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId))
}
const fuelConsumptionL = Number(payload.fuel?.consumption) || 0
const motorHours = Number(payload.motorHours) || 0
days.push({
entryId: entry.payloadId,
logbookId,
date: payload.date || '',
dayOfTravel: payload.dayOfTravel || '',
departure: payload.departure || '',
destination: payload.destination || '',
distanceNm,
distanceSource,
fuelConsumptionL,
freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0,
sailDistanceNm: propulsion.sailDistanceNm,
motorDistanceNm: propulsion.motorDistanceNm,
unknownPropulsionNm: propulsion.unknownPropulsionNm,
motorHours,
fuelPerMotorHourL: computeFuelPerMotorHour(fuelConsumptionL, motorHours),
hasGpsTrack
})
}
days.sort(compareTravelDaysChronological)
trackSegments.sort((a, b) => Number(a.dayOfTravel) - Number(b.dayOfTravel))
return { days, trackSegments }
}
export async function loadLogbookStats(
logbookId: string,
title: string,
includeTracks = true
): Promise<LogbookStatsSummary> {
const { days, trackSegments } = await loadTravelDaysForLogbook(logbookId, includeTracks)
return {
logbookId,
title,
travelDays: days,
routePorts: buildRoutePorts(days),
trackSegments,
totals: buildTotals(days)
}
}
export async function loadAccountStats(includeTracks = false): Promise<AccountStatsSummary> {
const logbooks = await db.logbooks.toArray()
const summaries: LogbookStatsSummary[] = []
for (const lb of logbooks) {
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
summaries.push(await loadLogbookStats(lb.id, title, includeTracks))
}
summaries.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
const allDays = summaries.flatMap((s) => s.travelDays)
return {
logbooks: summaries,
totals: buildTotals(allDays)
}
}
export function getTrackColor(index: number): string {
return TRACK_COLORS[index % TRACK_COLORS.length]
}
export { formatHours, formatLiters, formatNm } from '../utils/numberFormat.js'