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.
This commit is contained in:
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
|||||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { Compass, Save, Check } from 'lucide-react'
|
import { Compass, Save, Check } from 'lucide-react'
|
||||||
|
import { parseAppDecimalOrZero } from '../utils/numberFormat.js'
|
||||||
|
|
||||||
interface DeviationFormProps {
|
interface DeviationFormProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -97,8 +98,8 @@ export default function DeviationForm({ logbookId, readOnly = false, preloadedDa
|
|||||||
const sanitizedDeviations: Record<number, number> = {}
|
const sanitizedDeviations: Record<number, number> = {}
|
||||||
headings.forEach((h) => {
|
headings.forEach((h) => {
|
||||||
const val = deviations[h] || ''
|
const val = deviations[h] || ''
|
||||||
const parsed = parseFloat(val.replace('+', '').trim())
|
const parsed = parseAppDecimalOrZero(val.replace('+', '').trim())
|
||||||
sanitizedDeviations[h] = isNaN(parsed) ? 0 : parsed
|
sanitizedDeviations[h] = parsed
|
||||||
})
|
})
|
||||||
|
|
||||||
const dataToSave = {
|
const dataToSave = {
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ import {
|
|||||||
liveTempRemark,
|
liveTempRemark,
|
||||||
liveWaterRemark
|
liveWaterRemark
|
||||||
} from '../utils/liveEventCodes.js'
|
} from '../utils/liveEventCodes.js'
|
||||||
|
import { formatAppDecimal, formatTankLiters, parseAppDecimal } from '../utils/numberFormat.js'
|
||||||
|
|
||||||
|
const formatSpeedKn = (speedKn: number) =>
|
||||||
|
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||||
import {
|
import {
|
||||||
@@ -921,45 +925,45 @@ export default function LiveLogView({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'fuel': {
|
case 'fuel': {
|
||||||
const liters = parseFloat(primary)
|
const liters = parseAppDecimal(primary)
|
||||||
if (!Number.isFinite(liters) || liters <= 0) return
|
if (liters == null || liters <= 0) return
|
||||||
setModal('none')
|
setModal('none')
|
||||||
void runQuickAction(async () => {
|
void runQuickAction(async () => {
|
||||||
await appendTankRefill(logbookId, entryId, 'fuel', liters, {
|
await appendTankRefill(logbookId, entryId, 'fuel', liters, {
|
||||||
remarks: liveFuelRemark(String(liters))
|
remarks: liveFuelRemark(formatTankLiters(liters))
|
||||||
})
|
})
|
||||||
}, 'fuel')
|
}, 'fuel')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'water': {
|
case 'water': {
|
||||||
const liters = parseFloat(primary)
|
const liters = parseAppDecimal(primary)
|
||||||
if (!Number.isFinite(liters) || liters <= 0) return
|
if (liters == null || liters <= 0) return
|
||||||
setModal('none')
|
setModal('none')
|
||||||
void runQuickAction(async () => {
|
void runQuickAction(async () => {
|
||||||
await appendTankRefill(logbookId, entryId, 'freshwater', liters, {
|
await appendTankRefill(logbookId, entryId, 'freshwater', liters, {
|
||||||
remarks: liveWaterRemark(String(liters))
|
remarks: liveWaterRemark(formatTankLiters(liters))
|
||||||
})
|
})
|
||||||
}, 'water')
|
}, 'water')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'sog': {
|
case 'sog': {
|
||||||
const speedKn = parseFloat(primary.replace(',', '.'))
|
const speedKn = parseAppDecimal(primary)
|
||||||
if (!Number.isFinite(speedKn) || speedKn < 0) return
|
if (speedKn == null || speedKn < 0) return
|
||||||
setModal('none')
|
setModal('none')
|
||||||
void runQuickAction(async () => {
|
void runQuickAction(async () => {
|
||||||
await appendQuickEvent(logbookId, entryId, {
|
await appendQuickEvent(logbookId, entryId, {
|
||||||
remarks: liveSogRemark(String(speedKn))
|
remarks: liveSogRemark(formatSpeedKn(speedKn))
|
||||||
})
|
})
|
||||||
}, 'sog')
|
}, 'sog')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'stw': {
|
case 'stw': {
|
||||||
const speedKn = parseFloat(primary.replace(',', '.'))
|
const speedKn = parseAppDecimal(primary)
|
||||||
if (!Number.isFinite(speedKn) || speedKn < 0) return
|
if (speedKn == null || speedKn < 0) return
|
||||||
setModal('none')
|
setModal('none')
|
||||||
void runQuickAction(async () => {
|
void runQuickAction(async () => {
|
||||||
await appendQuickEvent(logbookId, entryId, {
|
await appendQuickEvent(logbookId, entryId, {
|
||||||
remarks: liveStwRemark(String(speedKn))
|
remarks: liveStwRemark(formatSpeedKn(speedKn))
|
||||||
})
|
})
|
||||||
}, 'stw')
|
}, 'stw')
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -103,6 +103,17 @@ import {
|
|||||||
formatTankLitersForInput,
|
formatTankLitersForInput,
|
||||||
type VesselTankCapacities
|
type VesselTankCapacities
|
||||||
} from '../utils/tankCapacity.js'
|
} from '../utils/tankCapacity.js'
|
||||||
|
import {
|
||||||
|
formatAppCoordinate,
|
||||||
|
parseAppDecimal,
|
||||||
|
parseAppDecimalOrZero
|
||||||
|
} from '../utils/numberFormat.js'
|
||||||
|
|
||||||
|
function parseOptionalFormDecimal(input: string): number | undefined {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
return parseAppDecimal(trimmed) ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
function emptyTankLevels() {
|
function emptyTankLevels() {
|
||||||
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
||||||
@@ -137,19 +148,19 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
|||||||
greywater: gw ? { level: gw.level || 0 } : undefined,
|
greywater: gw ? { level: gw.level || 0 } : undefined,
|
||||||
trackDistanceNm:
|
trackDistanceNm:
|
||||||
trackDistance != null && trackDistance !== ''
|
trackDistance != null && trackDistance !== ''
|
||||||
? parseFloat(String(trackDistance))
|
? (parseAppDecimal(String(trackDistance)) ?? undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
trackSpeedMaxKn:
|
trackSpeedMaxKn:
|
||||||
trackSpeedMax != null && trackSpeedMax !== ''
|
trackSpeedMax != null && trackSpeedMax !== ''
|
||||||
? parseFloat(String(trackSpeedMax))
|
? (parseAppDecimal(String(trackSpeedMax)) ?? undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
trackSpeedAvgKn:
|
trackSpeedAvgKn:
|
||||||
trackSpeedAvg != null && trackSpeedAvg !== ''
|
trackSpeedAvg != null && trackSpeedAvg !== ''
|
||||||
? parseFloat(String(trackSpeedAvg))
|
? (parseAppDecimal(String(trackSpeedAvg)) ?? undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
motorHours:
|
motorHours:
|
||||||
motorHoursRaw != null && motorHoursRaw !== ''
|
motorHoursRaw != null && motorHoursRaw !== ''
|
||||||
? parseFloat(String(motorHoursRaw))
|
? (parseAppDecimal(String(motorHoursRaw)) ?? undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
events: (decrypted.events as LogEventPayload[]) || [],
|
events: (decrypted.events as LogEventPayload[]) || [],
|
||||||
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
|
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
|
||||||
@@ -324,22 +335,22 @@ export default function LogEntryEditor({
|
|||||||
departure,
|
departure,
|
||||||
destination,
|
destination,
|
||||||
freshwater: {
|
freshwater: {
|
||||||
morning: parseFloat(fwMorning) || 0,
|
morning: parseAppDecimalOrZero(fwMorning),
|
||||||
refilled: parseFloat(fwRefilled) || 0,
|
refilled: parseAppDecimalOrZero(fwRefilled),
|
||||||
evening: parseFloat(fwEvening) || 0,
|
evening: parseAppDecimalOrZero(fwEvening),
|
||||||
consumption: parseFloat(fwConsumption) || 0
|
consumption: parseAppDecimalOrZero(fwConsumption)
|
||||||
},
|
},
|
||||||
fuel: {
|
fuel: {
|
||||||
morning: parseFloat(fuelMorning) || 0,
|
morning: parseAppDecimalOrZero(fuelMorning),
|
||||||
refilled: parseFloat(fuelRefilled) || 0,
|
refilled: parseAppDecimalOrZero(fuelRefilled),
|
||||||
evening: parseFloat(fuelEvening) || 0,
|
evening: parseAppDecimalOrZero(fuelEvening),
|
||||||
consumption: parseFloat(fuelConsumption) || 0
|
consumption: parseAppDecimalOrZero(fuelConsumption)
|
||||||
},
|
},
|
||||||
greywater: { level: parseFloat(greywaterLevel) || 0 },
|
greywater: { level: parseAppDecimalOrZero(greywaterLevel) },
|
||||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
|
||||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
|
||||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
|
||||||
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
|
motorHours: parseOptionalFormDecimal(motorHours),
|
||||||
events: eventsOverride ?? events,
|
events: eventsOverride ?? events,
|
||||||
entryCrew
|
entryCrew
|
||||||
})
|
})
|
||||||
@@ -362,7 +373,7 @@ export default function LogEntryEditor({
|
|||||||
}, [readOnly, loading, logbookId, entryId, buildPayloadForSigning, date])
|
}, [readOnly, loading, logbookId, entryId, buildPayloadForSigning, date])
|
||||||
|
|
||||||
const fuelPerMotorHour = useMemo(
|
const fuelPerMotorHour = useMemo(
|
||||||
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
|
() => computeFuelPerMotorHour(parseAppDecimalOrZero(fuelConsumption), parseAppDecimalOrZero(motorHours)),
|
||||||
[fuelConsumption, motorHours]
|
[fuelConsumption, motorHours]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -698,18 +709,18 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
// Auto-calculate Freshwater Consumption
|
// Auto-calculate Freshwater Consumption
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const morning = parseFloat(fwMorning) || 0
|
const morning = parseAppDecimalOrZero(fwMorning)
|
||||||
const refilled = parseFloat(fwRefilled) || 0
|
const refilled = parseAppDecimalOrZero(fwRefilled)
|
||||||
const evening = parseFloat(fwEvening) || 0
|
const evening = parseAppDecimalOrZero(fwEvening)
|
||||||
const cons = morning + refilled - evening
|
const cons = morning + refilled - evening
|
||||||
setFwConsumption(cons >= 0 ? String(cons) : '0')
|
setFwConsumption(cons >= 0 ? String(cons) : '0')
|
||||||
}, [fwMorning, fwRefilled, fwEvening])
|
}, [fwMorning, fwRefilled, fwEvening])
|
||||||
|
|
||||||
// Auto-calculate Fuel Consumption
|
// Auto-calculate Fuel Consumption
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const morning = parseFloat(fuelMorning) || 0
|
const morning = parseAppDecimalOrZero(fuelMorning)
|
||||||
const refilled = parseFloat(fuelRefilled) || 0
|
const refilled = parseAppDecimalOrZero(fuelRefilled)
|
||||||
const evening = parseFloat(fuelEvening) || 0
|
const evening = parseAppDecimalOrZero(fuelEvening)
|
||||||
const cons = morning + refilled - evening
|
const cons = morning + refilled - evening
|
||||||
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
||||||
}, [fuelMorning, fuelRefilled, fuelEvening])
|
}, [fuelMorning, fuelRefilled, fuelEvening])
|
||||||
@@ -720,7 +731,7 @@ export default function LogEntryEditor({
|
|||||||
(tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null
|
(tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refilled = parseFloat(fwRefilled) || 0
|
const refilled = parseAppDecimalOrZero(fwRefilled)
|
||||||
if (fwRefilledMax == null) {
|
if (fwRefilledMax == null) {
|
||||||
if (fwRefilledNoCapacity && refilled > 0) {
|
if (fwRefilledNoCapacity && refilled > 0) {
|
||||||
setFwRefilled(formatTankLitersForInput(0))
|
setFwRefilled(formatTankLitersForInput(0))
|
||||||
@@ -734,14 +745,14 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fwEveningMax == null) return
|
if (fwEveningMax == null) return
|
||||||
const evening = parseFloat(fwEvening) || 0
|
const evening = parseAppDecimalOrZero(fwEvening)
|
||||||
if (evening > fwEveningMax) {
|
if (evening > fwEveningMax) {
|
||||||
setFwEvening(formatTankLitersForInput(fwEveningMax))
|
setFwEvening(formatTankLitersForInput(fwEveningMax))
|
||||||
}
|
}
|
||||||
}, [fwEveningMax, fwEvening])
|
}, [fwEveningMax, fwEvening])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refilled = parseFloat(fuelRefilled) || 0
|
const refilled = parseAppDecimalOrZero(fuelRefilled)
|
||||||
if (fuelRefilledMax == null) {
|
if (fuelRefilledMax == null) {
|
||||||
if (fuelRefilledNoCapacity && refilled > 0) {
|
if (fuelRefilledNoCapacity && refilled > 0) {
|
||||||
setFuelRefilled(formatTankLitersForInput(0))
|
setFuelRefilled(formatTankLitersForInput(0))
|
||||||
@@ -755,7 +766,7 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fuelEveningMax == null) return
|
if (fuelEveningMax == null) return
|
||||||
const evening = parseFloat(fuelEvening) || 0
|
const evening = parseAppDecimalOrZero(fuelEvening)
|
||||||
if (evening > fuelEveningMax) {
|
if (evening > fuelEveningMax) {
|
||||||
setFuelEvening(formatTankLitersForInput(fuelEveningMax))
|
setFuelEvening(formatTankLitersForInput(fuelEveningMax))
|
||||||
}
|
}
|
||||||
@@ -1042,8 +1053,8 @@ export default function LogEntryEditor({
|
|||||||
)
|
)
|
||||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||||
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
||||||
setEvGpsLat(Number(coord.lat).toFixed(6))
|
setEvGpsLat(formatAppCoordinate(Number(coord.lat)))
|
||||||
setEvGpsLng(Number(coord.lon).toFixed(6))
|
setEvGpsLng(formatAppCoordinate(Number(coord.lon)))
|
||||||
showAlert(t('logs.gps_fallback_success', { location: locationQuery }))
|
showAlert(t('logs.gps_fallback_success', { location: locationQuery }))
|
||||||
} else {
|
} else {
|
||||||
showAlert(t('logs.gps_fallback_failed'))
|
showAlert(t('logs.gps_fallback_failed'))
|
||||||
@@ -1124,8 +1135,8 @@ export default function LogEntryEditor({
|
|||||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||||
// If fetched by location, automatically pre-fill GPS coordinates
|
// If fetched by location, automatically pre-fill GPS coordinates
|
||||||
if (!hasGps && coord?.lat !== undefined && coord?.lon !== undefined) {
|
if (!hasGps && coord?.lat !== undefined && coord?.lon !== undefined) {
|
||||||
setEvGpsLat(Number(coord.lat).toFixed(6))
|
setEvGpsLat(formatAppCoordinate(Number(coord.lat)))
|
||||||
setEvGpsLng(Number(coord.lon).toFixed(6))
|
setEvGpsLng(formatAppCoordinate(Number(coord.lon)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseOwmCurrentWeather(data)
|
const parsed = parseOwmCurrentWeather(data)
|
||||||
@@ -1172,23 +1183,23 @@ export default function LogEntryEditor({
|
|||||||
dayOfTravel,
|
dayOfTravel,
|
||||||
departure,
|
departure,
|
||||||
destination,
|
destination,
|
||||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
|
||||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
|
||||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
|
||||||
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
|
motorHours: parseOptionalFormDecimal(motorHours),
|
||||||
freshwater: {
|
freshwater: {
|
||||||
morning: parseFloat(fwMorning) || 0,
|
morning: parseAppDecimalOrZero(fwMorning),
|
||||||
refilled: parseFloat(fwRefilled) || 0,
|
refilled: parseAppDecimalOrZero(fwRefilled),
|
||||||
evening: parseFloat(fwEvening) || 0,
|
evening: parseAppDecimalOrZero(fwEvening),
|
||||||
consumption: parseFloat(fwConsumption) || 0
|
consumption: parseAppDecimalOrZero(fwConsumption)
|
||||||
},
|
},
|
||||||
fuel: {
|
fuel: {
|
||||||
morning: parseFloat(fuelMorning) || 0,
|
morning: parseAppDecimalOrZero(fuelMorning),
|
||||||
refilled: parseFloat(fuelRefilled) || 0,
|
refilled: parseAppDecimalOrZero(fuelRefilled),
|
||||||
evening: parseFloat(fuelEvening) || 0,
|
evening: parseAppDecimalOrZero(fuelEvening),
|
||||||
consumption: parseFloat(fuelConsumption) || 0
|
consumption: parseAppDecimalOrZero(fuelConsumption)
|
||||||
},
|
},
|
||||||
greywaterLevel: parseFloat(greywaterLevel) || 0,
|
greywaterLevel: parseAppDecimalOrZero(greywaterLevel),
|
||||||
events
|
events
|
||||||
},
|
},
|
||||||
t
|
t
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} 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'
|
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||||
|
import { formatAppDecimal } from '../utils/numberFormat.js'
|
||||||
import {
|
import {
|
||||||
loadLogbookEventSeries,
|
loadLogbookEventSeries,
|
||||||
type EventSeriesPoint,
|
type EventSeriesPoint,
|
||||||
@@ -211,8 +212,8 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="stats-propulsion-labels">
|
<div className="stats-propulsion-labels">
|
||||||
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span>
|
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(sailPct, { maximumFractionDigits: 0 })}%)</span>
|
||||||
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)</span>
|
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)</span>
|
||||||
{totals.unknownPropulsionNm > 0 && (
|
{totals.unknownPropulsionNm > 0 && (
|
||||||
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
|
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback } from 'react'
|
import React, { useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { clampTankLiters } from '../utils/tankCapacity.js'
|
import { clampTankLiters } from '../utils/tankCapacity.js'
|
||||||
|
import { formatTankLiters, parseAppDecimalOrZero } from '../utils/numberFormat.js'
|
||||||
|
|
||||||
interface TankLiterInputProps {
|
interface TankLiterInputProps {
|
||||||
id?: string
|
id?: string
|
||||||
@@ -13,10 +14,8 @@ interface TankLiterInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseInputLiters(value: string): number {
|
function parseInputLiters(value: string): number {
|
||||||
const trimmed = value.trim().replace(',', '.')
|
if (!value.trim()) return 0
|
||||||
if (!trimmed) return 0
|
return parseAppDecimalOrZero(value)
|
||||||
const parsed = Number(trimmed)
|
|
||||||
return Number.isFinite(parsed) ? parsed : 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TankLiterInput({
|
export default function TankLiterInput({
|
||||||
@@ -34,8 +33,7 @@ export default function TankLiterInput({
|
|||||||
const emitValue = useCallback(
|
const emitValue = useCallback(
|
||||||
(liters: number) => {
|
(liters: number) => {
|
||||||
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
|
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
|
||||||
const str =
|
const str = formatTankLiters(clamped)
|
||||||
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
|
|
||||||
onChange(str)
|
onChange(str)
|
||||||
},
|
},
|
||||||
[onChange, maxLiters, useSlider]
|
[onChange, maxLiters, useSlider]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatAppDecimal } from '../utils/numberFormat.js'
|
||||||
import { db } from './db.js'
|
import { db } from './db.js'
|
||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import {
|
import {
|
||||||
@@ -639,9 +640,10 @@ export function downloadBackupBlob(blob: Blob, filename: string): void {
|
|||||||
|
|
||||||
/** Human-readable size for UI warnings. */
|
/** Human-readable size for UI warnings. */
|
||||||
export function formatBackupBytes(bytes: number): string {
|
export function formatBackupBytes(bytes: number): string {
|
||||||
|
const fmt = (n: number) => formatAppDecimal(n, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
if (bytes < 1024) return `${bytes} B`
|
if (bytes < 1024) return `${bytes} B`
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
if (bytes < 1024 * 1024) return `${fmt(bytes / 1024)} KB`
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
return `${fmt(bytes / (1024 * 1024))} MB`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BACKUP_SIZE_WARN_BYTES = 50_000_000
|
export const BACKUP_SIZE_WARN_BYTES = 50_000_000
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import { formatAppDecimal } from '../../utils/numberFormat.js'
|
||||||
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
|
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
|
||||||
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
|
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
|
||||||
import { angularDelta } from './nmeaTimeSeries.js'
|
import { angularDelta } from './nmeaTimeSeries.js'
|
||||||
|
|
||||||
|
function formatNmeaDecimal(value: number): string {
|
||||||
|
return formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
|
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
|
||||||
const last = events[events.length - 1]
|
const last = events[events.length - 1]
|
||||||
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
|
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
|
||||||
@@ -64,7 +69,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'medium',
|
confidence: 'medium',
|
||||||
summaryKey: 'logs.nmea_change_wind_speed',
|
summaryKey: 'logs.nmea_change_wind_speed',
|
||||||
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastWindSpeed), to: formatNmeaDecimal(p.windSpeedKnots) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
@@ -79,7 +84,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'medium',
|
confidence: 'medium',
|
||||||
summaryKey: 'logs.nmea_change_pressure',
|
summaryKey: 'logs.nmea_change_pressure',
|
||||||
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastPressure), to: formatNmeaDecimal(p.pressureHpa) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
@@ -95,7 +100,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'high',
|
confidence: 'high',
|
||||||
summaryKey: 'logs.nmea_change_depth',
|
summaryKey: 'logs.nmea_change_depth',
|
||||||
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastDepth), to: formatNmeaDecimal(p.depthM) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
@@ -156,7 +161,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'medium',
|
confidence: 'medium',
|
||||||
summaryKey: 'logs.nmea_change_water_temp',
|
summaryKey: 'logs.nmea_change_water_temp',
|
||||||
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastWaterTemp), to: formatNmeaDecimal(p.waterTempC) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
@@ -200,7 +205,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'low',
|
confidence: 'low',
|
||||||
summaryKey: 'logs.nmea_change_speed',
|
summaryKey: 'logs.nmea_change_speed',
|
||||||
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastSog), to: formatNmeaDecimal(sog) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { TFunction } from 'i18next'
|
|||||||
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
|
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
|
||||||
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
|
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
|
||||||
import { formatCourseAngle } from '../../utils/courseAngle.js'
|
import { formatCourseAngle } from '../../utils/courseAngle.js'
|
||||||
|
import { formatAppDecimal, formatCanonicalCoordinate } from '../../utils/numberFormat.js'
|
||||||
import { degreesToCardinal } from '../../utils/courseAngle.js'
|
import { degreesToCardinal } from '../../utils/courseAngle.js'
|
||||||
import type {
|
import type {
|
||||||
NmeaChangeEvent,
|
NmeaChangeEvent,
|
||||||
@@ -33,9 +34,12 @@ function pointToLogEvent(
|
|||||||
windDirection: windDir,
|
windDirection: windDir,
|
||||||
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
|
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
|
||||||
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
|
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
|
||||||
gpsLat: point.lat != null ? point.lat.toFixed(6) : '',
|
gpsLat: point.lat != null ? formatCanonicalCoordinate(point.lat) : '',
|
||||||
gpsLng: point.lng != null ? point.lng.toFixed(6) : '',
|
gpsLng: point.lng != null ? formatCanonicalCoordinate(point.lng) : '',
|
||||||
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '',
|
logReading:
|
||||||
|
point.logDistanceNm != null
|
||||||
|
? formatAppDecimal(point.logDistanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
: '',
|
||||||
sailsOrMotor,
|
sailsOrMotor,
|
||||||
remarks
|
remarks
|
||||||
})
|
})
|
||||||
@@ -51,7 +55,11 @@ function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
|
|||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
|
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
|
||||||
if (change.data?.depthM != null) {
|
if (change.data?.depthM != null) {
|
||||||
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) }))
|
parts.push(
|
||||||
|
t('logs.nmea_remark_depth', {
|
||||||
|
depth: formatAppDecimal(change.data.depthM, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (change.confidence === 'low') {
|
if (change.confidence === 'low') {
|
||||||
parts.push(t('logs.nmea_remark_uncertain'))
|
parts.push(t('logs.nmea_remark_uncertain'))
|
||||||
|
|||||||
@@ -258,14 +258,4 @@ export function getTrackColor(index: number): string {
|
|||||||
return TRACK_COLORS[index % TRACK_COLORS.length]
|
return TRACK_COLORS[index % TRACK_COLORS.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatNm(value: number): string {
|
export { formatHours, formatLiters, formatNm } from '../utils/numberFormat.js'
|
||||||
return value.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatLiters(value: number): string {
|
|
||||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatHours(value: number): string {
|
|
||||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,4 @@ export function computeFuelPerMotorHour(
|
|||||||
return Number((fuelConsumptionL / motorHours).toFixed(2))
|
return Number((fuelConsumptionL / motorHours).toFixed(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFuelPerMotorHour(value: number | null | undefined): string {
|
export { formatFuelPerMotorHour } from './numberFormat.js'
|
||||||
if (value == null) return '—'
|
|
||||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import {
|
||||||
|
formatAppCoordinate,
|
||||||
|
formatCanonicalCoordinate,
|
||||||
|
formatGpsAccuracyMeters,
|
||||||
|
parseAppDecimal
|
||||||
|
} from './numberFormat.js'
|
||||||
|
|
||||||
const MPS_TO_KNOTS = 1.9438444924406
|
const MPS_TO_KNOTS = 1.9438444924406
|
||||||
|
|
||||||
/** Extra ms beyond the native timeout so hung browsers still reject. */
|
/** Extra ms beyond the native timeout so hung browsers still reject. */
|
||||||
@@ -30,13 +37,6 @@ export function gpsQualityI18nKey(quality: GpsSignalQuality): string {
|
|||||||
return `logs.gps_quality_${quality}`
|
return `logs.gps_quality_${quality}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Formats accuracy for i18n (±{{accuracy}} m): 1 m below 100 m, 10 m from 100 m upward. */
|
|
||||||
export function formatGpsAccuracyMeters(accuracyM: number): string {
|
|
||||||
return accuracyM < 100
|
|
||||||
? String(Math.round(accuracyM))
|
|
||||||
: String(Math.round(accuracyM / 10) * 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GeolocationPermissionState = PermissionState | 'unsupported'
|
export type GeolocationPermissionState = PermissionState | 'unsupported'
|
||||||
|
|
||||||
export type GeolocationErrorReason =
|
export type GeolocationErrorReason =
|
||||||
@@ -82,11 +82,10 @@ export interface GetPositionOptions {
|
|||||||
maximumAge?: number
|
maximumAge?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { formatGpsAccuracyMeters }
|
||||||
|
|
||||||
export function parseGpsCoordinate(value: string): number | null {
|
export function parseGpsCoordinate(value: string): number | null {
|
||||||
const trimmed = value.trim()
|
return parseAppDecimal(value.trim())
|
||||||
if (!trimmed) return null
|
|
||||||
const n = parseFloat(trimmed.replace(',', '.'))
|
|
||||||
return Number.isFinite(n) ? n : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validates lat/lng and returns normalized strings for storage, or null. */
|
/** Validates lat/lng and returns normalized strings for storage, or null. */
|
||||||
@@ -98,7 +97,7 @@ export function normalizeGpsCoordinates(
|
|||||||
const lngN = parseGpsCoordinate(lng)
|
const lngN = parseGpsCoordinate(lng)
|
||||||
if (latN == null || lngN == null) return null
|
if (latN == null || lngN == null) return null
|
||||||
if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null
|
if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null
|
||||||
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
|
return { lat: formatCanonicalCoordinate(latN), lng: formatCanonicalCoordinate(lngN) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** localStorage: user has seen the Live-Log geolocation intro (allow or dismiss). */
|
/** localStorage: user has seen the Live-Log geolocation intro (allow or dismiss). */
|
||||||
@@ -151,8 +150,8 @@ function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinat
|
|||||||
? pos.coords.accuracy
|
? pos.coords.accuracy
|
||||||
: null
|
: null
|
||||||
return {
|
return {
|
||||||
lat: pos.coords.latitude.toFixed(6),
|
lat: formatAppCoordinate(pos.coords.latitude),
|
||||||
lng: pos.coords.longitude.toFixed(6),
|
lng: formatAppCoordinate(pos.coords.longitude),
|
||||||
speedKn,
|
speedKn,
|
||||||
accuracyM,
|
accuracyM,
|
||||||
signalQuality: classifyGpsAccuracyMeters(accuracyM)
|
signalQuality: classifyGpsAccuracyMeters(accuracyM)
|
||||||
|
|||||||
@@ -56,10 +56,7 @@ export function emptyTankLevels(morning = 0): TankLevels {
|
|||||||
return { morning, refilled: 0, evening: 0, consumption: 0 }
|
return { morning, refilled: 0, evening: 0, consumption: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTankLiters(liters: number): string {
|
export { formatTankLiters } from './numberFormat.js'
|
||||||
if (!Number.isFinite(liters) || liters <= 0) return '0'
|
|
||||||
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
|
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
|
||||||
return Number(greywater?.level) || 0
|
return Number(greywater?.level) || 0
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
formatAppCoordinate,
|
||||||
|
formatAppDecimal,
|
||||||
|
formatGpsAccuracyMeters,
|
||||||
|
formatTankLiters,
|
||||||
|
getNumberFormatSymbols,
|
||||||
|
parseAppDecimal,
|
||||||
|
resolveDeviceLocale
|
||||||
|
} from './numberFormat.js'
|
||||||
|
|
||||||
|
describe('numberFormat (device locale)', () => {
|
||||||
|
it('resolveDeviceLocale returns a non-empty BCP 47 tag', () => {
|
||||||
|
expect(resolveDeviceLocale().length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads decimal separator from Intl for de-DE and en-US', () => {
|
||||||
|
expect(getNumberFormatSymbols('de-DE').decimal).toBe(',')
|
||||||
|
expect(getNumberFormatSymbols('en-US').decimal).toBe('.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats decimals per locale without grouping', () => {
|
||||||
|
expect(formatAppDecimal(12.5, { maximumFractionDigits: 1, locale: 'de-DE' })).toBe('12,5')
|
||||||
|
expect(formatAppDecimal(12.5, { maximumFractionDigits: 1, locale: 'en-US' })).toBe('12.5')
|
||||||
|
expect(formatAppDecimal(1234.5, { maximumFractionDigits: 1, locale: 'de-DE' })).toBe('1234,5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses device-locale decimals and tolerates the other separator', () => {
|
||||||
|
expect(parseAppDecimal('12,5', 'de-DE')).toBe(12.5)
|
||||||
|
expect(parseAppDecimal('12.5', 'en-US')).toBe(12.5)
|
||||||
|
expect(parseAppDecimal('12,5', 'en-US')).toBe(12.5)
|
||||||
|
expect(parseAppDecimal('1.234,5', 'de-DE')).toBe(1234.5)
|
||||||
|
expect(parseAppDecimal('', 'de-DE')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats coordinates for form display', () => {
|
||||||
|
expect(formatAppCoordinate(59.912345, 'de-DE')).toBe('59,912345')
|
||||||
|
expect(formatTankLiters(12.5)).toBe(formatAppDecimal(12.5, { minimumFractionDigits: 1, maximumFractionDigits: 1 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats GPS accuracy with coarse step from 100 m', () => {
|
||||||
|
expect(formatGpsAccuracyMeters(12.4)).toBe(formatAppDecimal(12, { maximumFractionDigits: 0 }))
|
||||||
|
expect(formatGpsAccuracyMeters(105)).toBe(formatAppDecimal(110, { maximumFractionDigits: 0 }))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Number formatting and parsing follow the device (browser) locale from Intl,
|
||||||
|
* not the app UI language — e.g. de-DE phone with English UI still uses comma decimals.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function resolveDeviceLocale(): string {
|
||||||
|
try {
|
||||||
|
const locale = new Intl.NumberFormat().resolvedOptions().locale
|
||||||
|
if (locale) return locale
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.language) {
|
||||||
|
return navigator.language
|
||||||
|
}
|
||||||
|
return 'en-GB'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NumberSymbols {
|
||||||
|
decimal: string
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const symbolCache = new Map<string, NumberSymbols>()
|
||||||
|
|
||||||
|
export function getNumberFormatSymbols(locale = resolveDeviceLocale()): NumberSymbols {
|
||||||
|
const cached = symbolCache.get(locale)
|
||||||
|
if (cached) return cached
|
||||||
|
const parts = new Intl.NumberFormat(locale).formatToParts(1234567.89)
|
||||||
|
const symbols: NumberSymbols = {
|
||||||
|
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
|
||||||
|
group: parts.find((p) => p.type === 'group')?.value ?? ''
|
||||||
|
}
|
||||||
|
symbolCache.set(locale, symbols)
|
||||||
|
return symbols
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormatAppDecimalOptions {
|
||||||
|
minimumFractionDigits?: number
|
||||||
|
maximumFractionDigits?: number
|
||||||
|
locale?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-visible decimal without thousands grouping. */
|
||||||
|
export function formatAppDecimal(value: number, options: FormatAppDecimalOptions = {}): string {
|
||||||
|
if (!Number.isFinite(value)) return ''
|
||||||
|
const locale = options.locale ?? resolveDeviceLocale()
|
||||||
|
const min = options.minimumFractionDigits ?? 0
|
||||||
|
const max = options.maximumFractionDigits ?? min
|
||||||
|
return new Intl.NumberFormat(locale, {
|
||||||
|
minimumFractionDigits: min,
|
||||||
|
maximumFractionDigits: max,
|
||||||
|
useGrouping: false
|
||||||
|
}).format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a decimal typed by the user for the device locale.
|
||||||
|
* Also accepts the other common separator for simple values (e.g. 12,5 on en-US).
|
||||||
|
*/
|
||||||
|
export function parseAppDecimal(input: string, locale = resolveDeviceLocale()): number | null {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
|
||||||
|
const { decimal, group } = getNumberFormatSymbols(locale)
|
||||||
|
const simpleComma = /^-?\d+,\d+$/.test(trimmed)
|
||||||
|
const simpleDot = /^-?\d+\.\d+$/.test(trimmed)
|
||||||
|
|
||||||
|
// Values without grouping: accept locale decimal and the other common separator.
|
||||||
|
if (simpleComma && decimal === ',') {
|
||||||
|
return Number(trimmed.replace(',', '.'))
|
||||||
|
}
|
||||||
|
if (simpleDot && decimal === '.') {
|
||||||
|
return Number(trimmed)
|
||||||
|
}
|
||||||
|
if (simpleComma && decimal === '.') {
|
||||||
|
return Number(trimmed.replace(',', '.'))
|
||||||
|
}
|
||||||
|
if (simpleDot && decimal === ',') {
|
||||||
|
return Number(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = trimmed
|
||||||
|
if (group) {
|
||||||
|
normalized = normalized.split(group).join('')
|
||||||
|
}
|
||||||
|
if (decimal !== '.') {
|
||||||
|
normalized = normalized.replace(decimal, '.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = Number(normalized)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAppDecimalOrZero(input: string, locale?: string): number {
|
||||||
|
return parseAppDecimal(input, locale) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical storage/API coordinate string (always dot, 6 decimals). */
|
||||||
|
export function formatCanonicalCoordinate(value: number): string {
|
||||||
|
return value.toFixed(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coordinate string for form fields (device decimal separator). */
|
||||||
|
export function formatAppCoordinate(value: number, locale?: string): string {
|
||||||
|
return formatAppDecimal(value, { minimumFractionDigits: 6, maximumFractionDigits: 6, locale })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNm(value: number): string {
|
||||||
|
return formatAppDecimal(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLiters(value: number): string {
|
||||||
|
return Number.isInteger(value)
|
||||||
|
? formatAppDecimal(value, { maximumFractionDigits: 0 })
|
||||||
|
: formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHours(value: number): string {
|
||||||
|
return formatLiters(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTankLiters(liters: number): string {
|
||||||
|
if (!Number.isFinite(liters) || liters <= 0) return formatAppDecimal(0, { maximumFractionDigits: 0 })
|
||||||
|
return formatLiters(liters)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFuelPerMotorHour(value: number | null | undefined): string {
|
||||||
|
if (value == null) return '—'
|
||||||
|
return Number.isInteger(value)
|
||||||
|
? formatAppDecimal(value, { maximumFractionDigits: 0 })
|
||||||
|
: formatAppDecimal(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GPS accuracy for i18n (±{{accuracy}} m): 1 m below 100 m, 10 m from 100 m upward. */
|
||||||
|
export function formatGpsAccuracyMeters(accuracyM: number): string {
|
||||||
|
const rounded = accuracyM < 100 ? Math.round(accuracyM) : Math.round(accuracyM / 10) * 10
|
||||||
|
return formatAppDecimal(rounded, { maximumFractionDigits: 0 })
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { degreesToCardinal } from './courseAngle.js'
|
import { degreesToCardinal } from './courseAngle.js'
|
||||||
|
import { formatAppDecimal } from './numberFormat.js'
|
||||||
import { formatVisibilityMeters } from './weatherMetrics.js'
|
import { formatVisibilityMeters } from './weatherMetrics.js'
|
||||||
|
|
||||||
/** @deprecated Use formatVisibilityMeters */
|
/** @deprecated Use formatVisibilityMeters */
|
||||||
@@ -33,7 +34,7 @@ export function mpsToBeaufort(mps: number): number {
|
|||||||
|
|
||||||
export function formatWindStrengthBeaufort(mps: number): string {
|
export function formatWindStrengthBeaufort(mps: number): string {
|
||||||
const bft = mpsToBeaufort(mps)
|
const bft = mpsToBeaufort(mps)
|
||||||
return `${bft} Bft (${mps.toFixed(1)} m/s)`
|
return `${bft} Bft (${formatAppDecimal(mps, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} m/s)`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwmCurrent {
|
export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwmCurrent {
|
||||||
@@ -49,7 +50,7 @@ export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwm
|
|||||||
|
|
||||||
let tempC: string | null = null
|
let tempC: string | null = null
|
||||||
if (main?.temp != null && Number.isFinite(main.temp)) {
|
if (main?.temp != null && Number.isFinite(main.temp)) {
|
||||||
tempC = Number(main.temp).toFixed(1)
|
tempC = formatAppDecimal(main.temp, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
}
|
}
|
||||||
|
|
||||||
let precipText: string | null = null
|
let precipText: string | null = null
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatTankLiters } from './logEntryTankLevels.js'
|
import { formatTankLiters, parseAppDecimal } from './numberFormat.js'
|
||||||
|
|
||||||
export interface VesselTankCapacities {
|
export interface VesselTankCapacities {
|
||||||
freshwaterCapacityL?: number
|
freshwaterCapacityL?: number
|
||||||
@@ -7,10 +7,10 @@ export interface VesselTankCapacities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseOptionalTankLiters(input: string): number | undefined {
|
export function parseOptionalTankLiters(input: string): number | undefined {
|
||||||
const trimmed = input.trim().replace(',', '.')
|
const trimmed = input.trim()
|
||||||
if (!trimmed) return undefined
|
if (!trimmed) return undefined
|
||||||
const parsed = Number(trimmed)
|
const parsed = parseAppDecimal(trimmed)
|
||||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
if (parsed == null || parsed < 0) {
|
||||||
throw new Error('invalid_tank_liters')
|
throw new Error('invalid_tank_liters')
|
||||||
}
|
}
|
||||||
return parsed
|
return parsed
|
||||||
@@ -24,10 +24,10 @@ function capacityFromStored(value: unknown): number | undefined {
|
|||||||
if (value == null || value === '') return undefined
|
if (value == null || value === '') return undefined
|
||||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
|
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const trimmed = value.trim().replace(',', '.')
|
const trimmed = value.trim()
|
||||||
if (!trimmed) return undefined
|
if (!trimmed) return undefined
|
||||||
const parsed = Number(trimmed)
|
const parsed = parseAppDecimal(trimmed)
|
||||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed
|
if (parsed != null && parsed >= 0) return parsed
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||||
|
import { formatAppDecimal } from './numberFormat.js'
|
||||||
|
|
||||||
const NM_IN_METERS = 1852
|
const NM_IN_METERS = 1852
|
||||||
const MAX_PLAUSIBLE_KNOTS = 50
|
const MAX_PLAUSIBLE_KNOTS = 50
|
||||||
@@ -100,8 +101,14 @@ export function formatTrackStats(stats: TrackStats): {
|
|||||||
speedAvgKn: string
|
speedAvgKn: string
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
distanceNm: stats.distanceNm.toFixed(2),
|
distanceNm: formatAppDecimal(stats.distanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
|
||||||
speedMaxKn: stats.speedMaxKn > 0 ? stats.speedMaxKn.toFixed(1) : '',
|
speedMaxKn:
|
||||||
speedAvgKn: stats.speedAvgKn > 0 ? stats.speedAvgKn.toFixed(1) : ''
|
stats.speedMaxKn > 0
|
||||||
|
? formatAppDecimal(stats.speedMaxKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
: '',
|
||||||
|
speedAvgKn:
|
||||||
|
stats.speedAvgKn > 0
|
||||||
|
? formatAppDecimal(stats.speedAvgKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { parseOptionalTankLiters, tankCapacityInputFromStored } from './tankCapacity.js'
|
import { parseOptionalTankLiters, tankCapacityInputFromStored } from './tankCapacity.js'
|
||||||
|
import { formatAppDecimal, parseAppDecimal } from './numberFormat.js'
|
||||||
import type { VesselData } from '../types/vessel.js'
|
import type { VesselData } from '../types/vessel.js'
|
||||||
|
|
||||||
export function metricInputFromStored(value: unknown): string {
|
export function metricInputFromStored(value: unknown): string {
|
||||||
if (value == null || value === '') return ''
|
if (value == null || value === '') return ''
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return formatAppDecimal(value, { maximumFractionDigits: 6 })
|
||||||
|
}
|
||||||
if (typeof value === 'string') return value.trim()
|
if (typeof value === 'string') return value.trim()
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseOptionalMetricMeters(input: string): number | undefined {
|
export function parseOptionalMetricMeters(input: string): number | undefined {
|
||||||
const trimmed = input.trim().replace(',', '.')
|
const trimmed = input.trim()
|
||||||
if (!trimmed) return undefined
|
if (!trimmed) return undefined
|
||||||
const parsed = Number(trimmed)
|
const parsed = parseAppDecimal(trimmed)
|
||||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
if (parsed == null || parsed < 0) {
|
||||||
throw new Error('invalid_metric')
|
throw new Error('invalid_metric')
|
||||||
}
|
}
|
||||||
return parsed
|
return parsed
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatAppDecimal } from './numberFormat.js'
|
||||||
|
|
||||||
/** Barometric pressure (hPa), typical marine range. */
|
/** Barometric pressure (hPa), typical marine range. */
|
||||||
export const PRESSURE_MIN_HPA = 960
|
export const PRESSURE_MIN_HPA = 960
|
||||||
export const PRESSURE_MAX_HPA = 1050
|
export const PRESSURE_MAX_HPA = 1050
|
||||||
@@ -90,7 +92,9 @@ export function formatVisibilityMeters(meters: number): string {
|
|||||||
if (meters >= 1000) {
|
if (meters >= 1000) {
|
||||||
const km = meters / 1000
|
const km = meters / 1000
|
||||||
const rounded = Math.round(km * 10) / 10
|
const rounded = Math.round(km * 10) / 10
|
||||||
return Number.isInteger(rounded) ? `${rounded} km` : `${rounded.toFixed(1)} km`
|
return Number.isInteger(rounded)
|
||||||
|
? `${formatAppDecimal(rounded, { maximumFractionDigits: 0 })} km`
|
||||||
|
: `${formatAppDecimal(rounded, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} km`
|
||||||
}
|
}
|
||||||
return `${Math.round(meters)} m`
|
return `${Math.round(meters)} m`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user