3cab735754
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.
262 lines
8.2 KiB
TypeScript
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'
|