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` }