From 3cab735754c1d254f4bfa0cebdd3b7dcfb296bfd Mon Sep 17 00:00:00 2001 From: elpatron Date: Wed, 3 Jun 2026 18:07:22 +0200 Subject: [PATCH] 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. --- client/src/components/DeviationForm.tsx | 5 +- client/src/components/LiveLogView.tsx | 28 ++-- client/src/components/LogEntryEditor.tsx | 101 +++++++------ client/src/components/StatsDashboard.tsx | 5 +- client/src/components/TankLiterInput.tsx | 10 +- client/src/services/logbookBackup.ts | 6 +- .../src/services/nmea/nmeaChangeDetection.ts | 15 +- .../src/services/nmea/nmeaJournalGenerator.ts | 16 +- client/src/services/statsAggregation.ts | 12 +- client/src/utils/fuelStats.ts | 5 +- client/src/utils/geolocation.ts | 27 ++-- client/src/utils/logEntryTankLevels.ts | 5 +- client/src/utils/numberFormat.test.ts | 45 ++++++ client/src/utils/numberFormat.ts | 139 ++++++++++++++++++ client/src/utils/openWeatherMap.ts | 5 +- client/src/utils/tankCapacity.ts | 14 +- client/src/utils/trackStats.ts | 13 +- client/src/utils/vesselFormUtils.ts | 11 +- client/src/utils/weatherMetrics.ts | 6 +- 19 files changed, 340 insertions(+), 128 deletions(-) create mode 100644 client/src/utils/numberFormat.test.ts create mode 100644 client/src/utils/numberFormat.ts diff --git a/client/src/components/DeviationForm.tsx b/client/src/components/DeviationForm.tsx index aa7f9ac..b3eb91c 100644 --- a/client/src/components/DeviationForm.tsx +++ b/client/src/components/DeviationForm.tsx @@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js' import { encryptJson, decryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.js' import { Compass, Save, Check } from 'lucide-react' +import { parseAppDecimalOrZero } from '../utils/numberFormat.js' interface DeviationFormProps { logbookId: string @@ -97,8 +98,8 @@ export default function DeviationForm({ logbookId, readOnly = false, preloadedDa const sanitizedDeviations: Record = {} headings.forEach((h) => { const val = deviations[h] || '' - const parsed = parseFloat(val.replace('+', '').trim()) - sanitizedDeviations[h] = isNaN(parsed) ? 0 : parsed + const parsed = parseAppDecimalOrZero(val.replace('+', '').trim()) + sanitizedDeviations[h] = parsed }) const dataToSave = { diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index 57b7355..6ef7734 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -50,6 +50,10 @@ import { liveTempRemark, liveWaterRemark } 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 { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { @@ -921,45 +925,45 @@ export default function LiveLogView({ break } case 'fuel': { - const liters = parseFloat(primary) - if (!Number.isFinite(liters) || liters <= 0) return + const liters = parseAppDecimal(primary) + if (liters == null || liters <= 0) return setModal('none') void runQuickAction(async () => { await appendTankRefill(logbookId, entryId, 'fuel', liters, { - remarks: liveFuelRemark(String(liters)) + remarks: liveFuelRemark(formatTankLiters(liters)) }) }, 'fuel') break } case 'water': { - const liters = parseFloat(primary) - if (!Number.isFinite(liters) || liters <= 0) return + const liters = parseAppDecimal(primary) + if (liters == null || liters <= 0) return setModal('none') void runQuickAction(async () => { await appendTankRefill(logbookId, entryId, 'freshwater', liters, { - remarks: liveWaterRemark(String(liters)) + remarks: liveWaterRemark(formatTankLiters(liters)) }) }, 'water') break } case 'sog': { - const speedKn = parseFloat(primary.replace(',', '.')) - if (!Number.isFinite(speedKn) || speedKn < 0) return + const speedKn = parseAppDecimal(primary) + if (speedKn == null || speedKn < 0) return setModal('none') void runQuickAction(async () => { await appendQuickEvent(logbookId, entryId, { - remarks: liveSogRemark(String(speedKn)) + remarks: liveSogRemark(formatSpeedKn(speedKn)) }) }, 'sog') break } case 'stw': { - const speedKn = parseFloat(primary.replace(',', '.')) - if (!Number.isFinite(speedKn) || speedKn < 0) return + const speedKn = parseAppDecimal(primary) + if (speedKn == null || speedKn < 0) return setModal('none') void runQuickAction(async () => { await appendQuickEvent(logbookId, entryId, { - remarks: liveStwRemark(String(speedKn)) + remarks: liveStwRemark(formatSpeedKn(speedKn)) }) }, 'stw') break diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index a06afd8..0550e4e 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -103,6 +103,17 @@ import { formatTankLitersForInput, type VesselTankCapacities } 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() { return { morning: 0, refilled: 0, evening: 0, consumption: 0 } @@ -137,19 +148,19 @@ function fingerprintFromStoredEntry(decrypted: Record): string greywater: gw ? { level: gw.level || 0 } : undefined, trackDistanceNm: trackDistance != null && trackDistance !== '' - ? parseFloat(String(trackDistance)) + ? (parseAppDecimal(String(trackDistance)) ?? undefined) : undefined, trackSpeedMaxKn: trackSpeedMax != null && trackSpeedMax !== '' - ? parseFloat(String(trackSpeedMax)) + ? (parseAppDecimal(String(trackSpeedMax)) ?? undefined) : undefined, trackSpeedAvgKn: trackSpeedAvg != null && trackSpeedAvg !== '' - ? parseFloat(String(trackSpeedAvg)) + ? (parseAppDecimal(String(trackSpeedAvg)) ?? undefined) : undefined, motorHours: motorHoursRaw != null && motorHoursRaw !== '' - ? parseFloat(String(motorHoursRaw)) + ? (parseAppDecimal(String(motorHoursRaw)) ?? undefined) : undefined, events: (decrypted.events as LogEventPayload[]) || [], entryCrew: entryCrewFromPreviousEntry(decrypted as Record) @@ -324,22 +335,22 @@ export default function LogEntryEditor({ departure, destination, freshwater: { - morning: parseFloat(fwMorning) || 0, - refilled: parseFloat(fwRefilled) || 0, - evening: parseFloat(fwEvening) || 0, - consumption: parseFloat(fwConsumption) || 0 + morning: parseAppDecimalOrZero(fwMorning), + refilled: parseAppDecimalOrZero(fwRefilled), + evening: parseAppDecimalOrZero(fwEvening), + consumption: parseAppDecimalOrZero(fwConsumption) }, fuel: { - morning: parseFloat(fuelMorning) || 0, - refilled: parseFloat(fuelRefilled) || 0, - evening: parseFloat(fuelEvening) || 0, - consumption: parseFloat(fuelConsumption) || 0 + morning: parseAppDecimalOrZero(fuelMorning), + refilled: parseAppDecimalOrZero(fuelRefilled), + evening: parseAppDecimalOrZero(fuelEvening), + consumption: parseAppDecimalOrZero(fuelConsumption) }, - greywater: { level: parseFloat(greywaterLevel) || 0 }, - trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined, - trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined, - trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined, - motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined, + greywater: { level: parseAppDecimalOrZero(greywaterLevel) }, + trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm), + trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn), + trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn), + motorHours: parseOptionalFormDecimal(motorHours), events: eventsOverride ?? events, entryCrew }) @@ -362,7 +373,7 @@ export default function LogEntryEditor({ }, [readOnly, loading, logbookId, entryId, buildPayloadForSigning, date]) const fuelPerMotorHour = useMemo( - () => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0), + () => computeFuelPerMotorHour(parseAppDecimalOrZero(fuelConsumption), parseAppDecimalOrZero(motorHours)), [fuelConsumption, motorHours] ) @@ -698,18 +709,18 @@ export default function LogEntryEditor({ // Auto-calculate Freshwater Consumption useEffect(() => { - const morning = parseFloat(fwMorning) || 0 - const refilled = parseFloat(fwRefilled) || 0 - const evening = parseFloat(fwEvening) || 0 + const morning = parseAppDecimalOrZero(fwMorning) + const refilled = parseAppDecimalOrZero(fwRefilled) + const evening = parseAppDecimalOrZero(fwEvening) const cons = morning + refilled - evening setFwConsumption(cons >= 0 ? String(cons) : '0') }, [fwMorning, fwRefilled, fwEvening]) // Auto-calculate Fuel Consumption useEffect(() => { - const morning = parseFloat(fuelMorning) || 0 - const refilled = parseFloat(fuelRefilled) || 0 - const evening = parseFloat(fuelEvening) || 0 + const morning = parseAppDecimalOrZero(fuelMorning) + const refilled = parseAppDecimalOrZero(fuelRefilled) + const evening = parseAppDecimalOrZero(fuelEvening) const cons = morning + refilled - evening setFuelConsumption(cons >= 0 ? String(cons) : '0') }, [fuelMorning, fuelRefilled, fuelEvening]) @@ -720,7 +731,7 @@ export default function LogEntryEditor({ (tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null useEffect(() => { - const refilled = parseFloat(fwRefilled) || 0 + const refilled = parseAppDecimalOrZero(fwRefilled) if (fwRefilledMax == null) { if (fwRefilledNoCapacity && refilled > 0) { setFwRefilled(formatTankLitersForInput(0)) @@ -734,14 +745,14 @@ export default function LogEntryEditor({ useEffect(() => { if (fwEveningMax == null) return - const evening = parseFloat(fwEvening) || 0 + const evening = parseAppDecimalOrZero(fwEvening) if (evening > fwEveningMax) { setFwEvening(formatTankLitersForInput(fwEveningMax)) } }, [fwEveningMax, fwEvening]) useEffect(() => { - const refilled = parseFloat(fuelRefilled) || 0 + const refilled = parseAppDecimalOrZero(fuelRefilled) if (fuelRefilledMax == null) { if (fuelRefilledNoCapacity && refilled > 0) { setFuelRefilled(formatTankLitersForInput(0)) @@ -755,7 +766,7 @@ export default function LogEntryEditor({ useEffect(() => { if (fuelEveningMax == null) return - const evening = parseFloat(fuelEvening) || 0 + const evening = parseAppDecimalOrZero(fuelEvening) if (evening > fuelEveningMax) { setFuelEvening(formatTankLitersForInput(fuelEveningMax)) } @@ -1042,8 +1053,8 @@ export default function LogEntryEditor({ ) const coord = data.coord as { lat?: number; lon?: number } | undefined if (coord?.lat !== undefined && coord?.lon !== undefined) { - setEvGpsLat(Number(coord.lat).toFixed(6)) - setEvGpsLng(Number(coord.lon).toFixed(6)) + setEvGpsLat(formatAppCoordinate(Number(coord.lat))) + setEvGpsLng(formatAppCoordinate(Number(coord.lon))) showAlert(t('logs.gps_fallback_success', { location: locationQuery })) } else { showAlert(t('logs.gps_fallback_failed')) @@ -1124,8 +1135,8 @@ export default function LogEntryEditor({ const coord = data.coord as { lat?: number; lon?: number } | undefined // If fetched by location, automatically pre-fill GPS coordinates if (!hasGps && coord?.lat !== undefined && coord?.lon !== undefined) { - setEvGpsLat(Number(coord.lat).toFixed(6)) - setEvGpsLng(Number(coord.lon).toFixed(6)) + setEvGpsLat(formatAppCoordinate(Number(coord.lat))) + setEvGpsLng(formatAppCoordinate(Number(coord.lon))) } const parsed = parseOwmCurrentWeather(data) @@ -1172,23 +1183,23 @@ export default function LogEntryEditor({ dayOfTravel, departure, destination, - trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined, - trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined, - trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined, - motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined, + trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm), + trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn), + trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn), + motorHours: parseOptionalFormDecimal(motorHours), freshwater: { - morning: parseFloat(fwMorning) || 0, - refilled: parseFloat(fwRefilled) || 0, - evening: parseFloat(fwEvening) || 0, - consumption: parseFloat(fwConsumption) || 0 + morning: parseAppDecimalOrZero(fwMorning), + refilled: parseAppDecimalOrZero(fwRefilled), + evening: parseAppDecimalOrZero(fwEvening), + consumption: parseAppDecimalOrZero(fwConsumption) }, fuel: { - morning: parseFloat(fuelMorning) || 0, - refilled: parseFloat(fuelRefilled) || 0, - evening: parseFloat(fuelEvening) || 0, - consumption: parseFloat(fuelConsumption) || 0 + morning: parseAppDecimalOrZero(fuelMorning), + refilled: parseAppDecimalOrZero(fuelRefilled), + evening: parseAppDecimalOrZero(fuelEvening), + consumption: parseAppDecimalOrZero(fuelConsumption) }, - greywaterLevel: parseFloat(greywaterLevel) || 0, + greywaterLevel: parseAppDecimalOrZero(greywaterLevel), events }, t diff --git a/client/src/components/StatsDashboard.tsx b/client/src/components/StatsDashboard.tsx index 003f794..d729157 100644 --- a/client/src/components/StatsDashboard.tsx +++ b/client/src/components/StatsDashboard.tsx @@ -14,6 +14,7 @@ import { } from '../services/statsAggregation.js' import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js' import { formatFuelPerMotorHour } from '../utils/fuelStats.js' +import { formatAppDecimal } from '../utils/numberFormat.js' import { loadLogbookEventSeries, type EventSeriesPoint, @@ -211,8 +212,8 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) { )}
- {t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%) - {t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%) + {t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(sailPct, { maximumFractionDigits: 0 })}%) + {t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%) {totals.unknownPropulsionNm > 0 && ( {t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')} )} diff --git a/client/src/components/TankLiterInput.tsx b/client/src/components/TankLiterInput.tsx index f5c717b..b627ba2 100644 --- a/client/src/components/TankLiterInput.tsx +++ b/client/src/components/TankLiterInput.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { clampTankLiters } from '../utils/tankCapacity.js' +import { formatTankLiters, parseAppDecimalOrZero } from '../utils/numberFormat.js' interface TankLiterInputProps { id?: string @@ -13,10 +14,8 @@ interface TankLiterInputProps { } function parseInputLiters(value: string): number { - const trimmed = value.trim().replace(',', '.') - if (!trimmed) return 0 - const parsed = Number(trimmed) - return Number.isFinite(parsed) ? parsed : 0 + if (!value.trim()) return 0 + return parseAppDecimalOrZero(value) } export default function TankLiterInput({ @@ -34,8 +33,7 @@ export default function TankLiterInput({ const emitValue = useCallback( (liters: number) => { const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined) - const str = - Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1))) + const str = formatTankLiters(clamped) onChange(str) }, [onChange, maxLiters, useSlider] diff --git a/client/src/services/logbookBackup.ts b/client/src/services/logbookBackup.ts index 43a41bf..f15abab 100644 --- a/client/src/services/logbookBackup.ts +++ b/client/src/services/logbookBackup.ts @@ -1,3 +1,4 @@ +import { formatAppDecimal } from '../utils/numberFormat.js' import { db } from './db.js' import { getActiveMasterKey } from './auth.js' import { @@ -639,9 +640,10 @@ export function downloadBackupBlob(blob: Blob, filename: string): void { /** Human-readable size for UI warnings. */ 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 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + if (bytes < 1024 * 1024) return `${fmt(bytes / 1024)} KB` + return `${fmt(bytes / (1024 * 1024))} MB` } export const BACKUP_SIZE_WARN_BYTES = 50_000_000 diff --git a/client/src/services/nmea/nmeaChangeDetection.ts b/client/src/services/nmea/nmeaChangeDetection.ts index ce7937d..fa2913c 100644 --- a/client/src/services/nmea/nmeaChangeDetection.ts +++ b/client/src/services/nmea/nmeaChangeDetection.ts @@ -1,7 +1,12 @@ +import { formatAppDecimal } from '../../utils/numberFormat.js' import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js' import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.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) { const last = events[events.length - 1] if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return @@ -64,7 +69,7 @@ export function detectNmeaChanges( timestamp: p.timestamp, confidence: 'medium', 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 }, config.dedupeWindowMs) } @@ -79,7 +84,7 @@ export function detectNmeaChanges( timestamp: p.timestamp, confidence: 'medium', 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 }, config.dedupeWindowMs) } @@ -95,7 +100,7 @@ export function detectNmeaChanges( timestamp: p.timestamp, confidence: 'high', 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 }, config.dedupeWindowMs) } @@ -156,7 +161,7 @@ export function detectNmeaChanges( timestamp: p.timestamp, confidence: 'medium', 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 }, config.dedupeWindowMs) } @@ -200,7 +205,7 @@ export function detectNmeaChanges( timestamp: p.timestamp, confidence: 'low', summaryKey: 'logs.nmea_change_speed', - summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) }, + summaryParams: { from: formatNmeaDecimal(lastSog), to: formatNmeaDecimal(sog) }, data: p }, config.dedupeWindowMs) } diff --git a/client/src/services/nmea/nmeaJournalGenerator.ts b/client/src/services/nmea/nmeaJournalGenerator.ts index 4437ded..e797d1b 100644 --- a/client/src/services/nmea/nmeaJournalGenerator.ts +++ b/client/src/services/nmea/nmeaJournalGenerator.ts @@ -2,6 +2,7 @@ import type { TFunction } from 'i18next' import type { LogEventPayload } from '../../utils/logEntryPayload.js' import { normalizeLogEvent } from '../../utils/logEntryPayload.js' import { formatCourseAngle } from '../../utils/courseAngle.js' +import { formatAppDecimal, formatCanonicalCoordinate } from '../../utils/numberFormat.js' import { degreesToCardinal } from '../../utils/courseAngle.js' import type { NmeaChangeEvent, @@ -33,9 +34,12 @@ function pointToLogEvent( windDirection: windDir, windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '', windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '', - gpsLat: point.lat != null ? point.lat.toFixed(6) : '', - gpsLng: point.lng != null ? point.lng.toFixed(6) : '', - logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '', + gpsLat: point.lat != null ? formatCanonicalCoordinate(point.lat) : '', + gpsLng: point.lng != null ? formatCanonicalCoordinate(point.lng) : '', + logReading: + point.logDistanceNm != null + ? formatAppDecimal(point.logDistanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + : '', sailsOrMotor, remarks }) @@ -51,7 +55,11 @@ function buildRemarks(change: NmeaChangeEvent, t: TFunction): string { const parts: string[] = [] parts.push(t(change.summaryKey, change.summaryParams ?? {})) 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') { parts.push(t('logs.nmea_remark_uncertain')) diff --git a/client/src/services/statsAggregation.ts b/client/src/services/statsAggregation.ts index 24fffc4..47b2988 100644 --- a/client/src/services/statsAggregation.ts +++ b/client/src/services/statsAggregation.ts @@ -258,14 +258,4 @@ export function getTrackColor(index: number): string { return TRACK_COLORS[index % TRACK_COLORS.length] } -export function formatNm(value: number): string { - 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) -} +export { formatHours, formatLiters, formatNm } from '../utils/numberFormat.js' diff --git a/client/src/utils/fuelStats.ts b/client/src/utils/fuelStats.ts index 55bd0be..6c089bb 100644 --- a/client/src/utils/fuelStats.ts +++ b/client/src/utils/fuelStats.ts @@ -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' diff --git a/client/src/utils/geolocation.ts b/client/src/utils/geolocation.ts index 7ac6025..0709c88 100644 --- a/client/src/utils/geolocation.ts +++ b/client/src/utils/geolocation.ts @@ -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) diff --git a/client/src/utils/logEntryTankLevels.ts b/client/src/utils/logEntryTankLevels.ts index ac85a0e..6f06684 100644 --- a/client/src/utils/logEntryTankLevels.ts +++ b/client/src/utils/logEntryTankLevels.ts @@ -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 diff --git a/client/src/utils/numberFormat.test.ts b/client/src/utils/numberFormat.test.ts new file mode 100644 index 0000000..af30c40 --- /dev/null +++ b/client/src/utils/numberFormat.test.ts @@ -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 })) + }) +}) diff --git a/client/src/utils/numberFormat.ts b/client/src/utils/numberFormat.ts new file mode 100644 index 0000000..f56ffb3 --- /dev/null +++ b/client/src/utils/numberFormat.ts @@ -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() + +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 }) +} diff --git a/client/src/utils/openWeatherMap.ts b/client/src/utils/openWeatherMap.ts index 2ef5f35..4f62d52 100644 --- a/client/src/utils/openWeatherMap.ts +++ b/client/src/utils/openWeatherMap.ts @@ -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): ParsedOwmCurrent { @@ -49,7 +50,7 @@ export function parseOwmCurrentWeather(data: Record): 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 diff --git a/client/src/utils/tankCapacity.ts b/client/src/utils/tankCapacity.ts index 44a2833..69259f8 100644 --- a/client/src/utils/tankCapacity.ts +++ b/client/src/utils/tankCapacity.ts @@ -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 } diff --git a/client/src/utils/trackStats.ts b/client/src/utils/trackStats.ts index 4194d3b..37d4eb4 100644 --- a/client/src/utils/trackStats.ts +++ b/client/src/utils/trackStats.ts @@ -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 }) + : '' } } diff --git a/client/src/utils/vesselFormUtils.ts b/client/src/utils/vesselFormUtils.ts index 33ec32e..5167a84 100644 --- a/client/src/utils/vesselFormUtils.ts +++ b/client/src/utils/vesselFormUtils.ts @@ -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 diff --git a/client/src/utils/weatherMetrics.ts b/client/src/utils/weatherMetrics.ts index 20aa63b..90e1cf7 100644 --- a/client/src/utils/weatherMetrics.ts +++ b/client/src/utils/weatherMetrics.ts @@ -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` }