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:
2026-06-03 18:07:22 +02:00
parent 79762a0baf
commit 3cab735754
19 changed files with 340 additions and 128 deletions
+1 -4
View File
@@ -7,7 +7,4 @@ export function computeFuelPerMotorHour(
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)
}
export { formatFuelPerMotorHour } from './numberFormat.js'
+13 -14
View File
@@ -1,3 +1,10 @@
import {
formatAppCoordinate,
formatCanonicalCoordinate,
formatGpsAccuracyMeters,
parseAppDecimal
} from './numberFormat.js'
const MPS_TO_KNOTS = 1.9438444924406
/** 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}`
}
/** 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 GeolocationErrorReason =
@@ -82,11 +82,10 @@ export interface GetPositionOptions {
maximumAge?: number
}
export { formatGpsAccuracyMeters }
export function parseGpsCoordinate(value: string): number | null {
const trimmed = value.trim()
if (!trimmed) return null
const n = parseFloat(trimmed.replace(',', '.'))
return Number.isFinite(n) ? n : null
return parseAppDecimal(value.trim())
}
/** Validates lat/lng and returns normalized strings for storage, or null. */
@@ -98,7 +97,7 @@ export function normalizeGpsCoordinates(
const lngN = parseGpsCoordinate(lng)
if (latN == null || lngN == null) 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). */
@@ -151,8 +150,8 @@ function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinat
? pos.coords.accuracy
: null
return {
lat: pos.coords.latitude.toFixed(6),
lng: pos.coords.longitude.toFixed(6),
lat: formatAppCoordinate(pos.coords.latitude),
lng: formatAppCoordinate(pos.coords.longitude),
speedKn,
accuracyM,
signalQuality: classifyGpsAccuracyMeters(accuracyM)
+1 -4
View File
@@ -56,10 +56,7 @@ export function emptyTankLevels(morning = 0): TankLevels {
return { morning, refilled: 0, evening: 0, consumption: 0 }
}
export function formatTankLiters(liters: number): string {
if (!Number.isFinite(liters) || liters <= 0) return '0'
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
}
export { formatTankLiters } from './numberFormat.js'
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
return Number(greywater?.level) || 0
+45
View File
@@ -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 }))
})
})
+139
View File
@@ -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 })
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { degreesToCardinal } from './courseAngle.js'
import { formatAppDecimal } from './numberFormat.js'
import { formatVisibilityMeters } from './weatherMetrics.js'
/** @deprecated Use formatVisibilityMeters */
@@ -33,7 +34,7 @@ export function mpsToBeaufort(mps: number): number {
export function formatWindStrengthBeaufort(mps: number): string {
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 {
@@ -49,7 +50,7 @@ export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwm
let tempC: string | null = null
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
+7 -7
View File
@@ -1,4 +1,4 @@
import { formatTankLiters } from './logEntryTankLevels.js'
import { formatTankLiters, parseAppDecimal } from './numberFormat.js'
export interface VesselTankCapacities {
freshwaterCapacityL?: number
@@ -7,10 +7,10 @@ export interface VesselTankCapacities {
}
export function parseOptionalTankLiters(input: string): number | undefined {
const trimmed = input.trim().replace(',', '.')
const trimmed = input.trim()
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (!Number.isFinite(parsed) || parsed < 0) {
const parsed = parseAppDecimal(trimmed)
if (parsed == null || parsed < 0) {
throw new Error('invalid_tank_liters')
}
return parsed
@@ -24,10 +24,10 @@ function capacityFromStored(value: unknown): number | undefined {
if (value == null || value === '') return undefined
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
if (typeof value === 'string') {
const trimmed = value.trim().replace(',', '.')
const trimmed = value.trim()
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (Number.isFinite(parsed) && parsed >= 0) return parsed
const parsed = parseAppDecimal(trimmed)
if (parsed != null && parsed >= 0) return parsed
}
return undefined
}
+10 -3
View File
@@ -1,4 +1,5 @@
import type { TrackWaypoint } from '../services/trackUpload.js'
import { formatAppDecimal } from './numberFormat.js'
const NM_IN_METERS = 1852
const MAX_PLAUSIBLE_KNOTS = 50
@@ -100,8 +101,14 @@ export function formatTrackStats(stats: TrackStats): {
speedAvgKn: string
} {
return {
distanceNm: stats.distanceNm.toFixed(2),
speedMaxKn: stats.speedMaxKn > 0 ? stats.speedMaxKn.toFixed(1) : '',
speedAvgKn: stats.speedAvgKn > 0 ? stats.speedAvgKn.toFixed(1) : ''
distanceNm: formatAppDecimal(stats.distanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
speedMaxKn:
stats.speedMaxKn > 0
? formatAppDecimal(stats.speedMaxKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
: '',
speedAvgKn:
stats.speedAvgKn > 0
? formatAppDecimal(stats.speedAvgKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
: ''
}
}
+7 -4
View File
@@ -1,18 +1,21 @@
import { parseOptionalTankLiters, tankCapacityInputFromStored } from './tankCapacity.js'
import { formatAppDecimal, parseAppDecimal } from './numberFormat.js'
import type { VesselData } from '../types/vessel.js'
export function metricInputFromStored(value: unknown): string {
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()
return ''
}
export function parseOptionalMetricMeters(input: string): number | undefined {
const trimmed = input.trim().replace(',', '.')
const trimmed = input.trim()
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (!Number.isFinite(parsed) || parsed < 0) {
const parsed = parseAppDecimal(trimmed)
if (parsed == null || parsed < 0) {
throw new Error('invalid_metric')
}
return parsed
+5 -1
View File
@@ -1,3 +1,5 @@
import { formatAppDecimal } from './numberFormat.js'
/** Barometric pressure (hPa), typical marine range. */
export const PRESSURE_MIN_HPA = 960
export const PRESSURE_MAX_HPA = 1050
@@ -90,7 +92,9 @@ export function formatVisibilityMeters(meters: number): string {
if (meters >= 1000) {
const km = meters / 1000
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`
}