From 2caf1929a1e6cba4ccf3737b100acbdb2797b883 Mon Sep 17 00:00:00 2001 From: "Chavda Sachin (via MelvinBot)" Date: Thu, 5 Mar 2026 20:19:08 +0000 Subject: [PATCH 1/9] Normalize odometer input text before storing in state The odometer input handlers (handleStartReadingChange and handleEndReadingChange) were storing raw input text in state after validation. The isOdometerInputValid function validates the normalized text (with non-numeric characters stripped) but the raw text was stored, allowing letters and symbols to appear in the input field on web/desktop. This normalizes the text via normalizeOdometerText before calling setStartReading/setEndReading so non-numeric characters are stripped at keystroke time rather than only at submit time. Co-authored-by: Chavda Sachin --- .../request/step/IOURequestStepDistanceOdometer.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 421d7a07cf129..7eeacfa580079 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -308,8 +308,9 @@ function IOURequestStepDistanceOdometer({ if (!isOdometerInputValid(text, startReading)) { return; } - setStartReading(text); - startReadingRef.current = text; + const normalized = DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit); + setStartReading(normalized); + startReadingRef.current = normalized; if (formError) { setFormError(''); } @@ -319,8 +320,9 @@ function IOURequestStepDistanceOdometer({ if (!isOdometerInputValid(text, endReading)) { return; } - setEndReading(text); - endReadingRef.current = text; + const normalized = DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit); + setEndReading(normalized); + endReadingRef.current = normalized; if (formError) { setFormError(''); } From 879234a788c278cb23966868e11f51d3c8e2abcb Mon Sep 17 00:00:00 2001 From: "Chavda Sachin (via MelvinBot)" Date: Mon, 16 Mar 2026 23:17:03 +0000 Subject: [PATCH 2/9] Strip redundant leading zeroes from odometer input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeOdometerText now collapses leading zeroes so users cannot enter infinite zeroes (e.g. "000" → "0", "007" → "7"). A single zero before a decimal point is preserved ("0.5" stays "0.5"). Co-authored-by: Chavda Sachin --- src/libs/DistanceRequestUtils.ts | 5 ++++- tests/unit/OdometerNormalizationTest.ts | 28 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index db7712eab8909..874079e2639b7 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -479,7 +479,10 @@ function isDistanceAmountWithinLimit(distance: number, rate: number): boolean { */ function normalizeOdometerText(text: string, fromLocaleDigit: (char: string) => string): string { const standardized = replaceAllDigits(text, fromLocaleDigit); - return standardized.replaceAll(/[^0-9.]/g, ''); + const stripped = standardized.replaceAll(/[^0-9.]/g, ''); + // Remove redundant leading zeroes (e.g. "007" → "7", "000" → "0") but + // keep a single zero before a decimal point (e.g. "0.5" stays "0.5"). + return stripped.replace(/^0+(?=\d)/, ''); } export default { diff --git a/tests/unit/OdometerNormalizationTest.ts b/tests/unit/OdometerNormalizationTest.ts index 85180cedfa076..4db2aa4cbb80a 100644 --- a/tests/unit/OdometerNormalizationTest.ts +++ b/tests/unit/OdometerNormalizationTest.ts @@ -37,4 +37,32 @@ describe('normalizeOdometerText', () => { expect(DistanceRequestUtils.normalizeOdometerText('9999999', englishFromLocaleDigit)).toBe('9999999'); }); }); + + describe('leading zeroes', () => { + const identity = (char: string) => char; + + it("strips redundant leading zeroes: '000' → '0'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('000', identity)).toBe('0'); + }); + + it("strips leading zeroes before digits: '007' → '7'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('007', identity)).toBe('7'); + }); + + it("keeps single zero before decimal: '0.5' → '0.5'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('0.5', identity)).toBe('0.5'); + }); + + it("normalizes multiple zeroes before decimal: '00.5' → '0.5'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('00.5', identity)).toBe('0.5'); + }); + + it("keeps a single zero: '0' → '0'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('0', identity)).toBe('0'); + }); + + it("does not affect numbers without leading zeroes: '123' → '123'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('123', identity)).toBe('123'); + }); + }); }); From 5db14d00400842ad7fa6916a0a33bf1dff71c125 Mon Sep 17 00:00:00 2001 From: "Chavda Sachin (via MelvinBot)" Date: Tue, 17 Mar 2026 17:13:20 +0000 Subject: [PATCH 3/9] Support comma as decimal separator in odometer input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeOdometerText now converts commas to periods so both "." and "," work as decimal input in any locale. When this produces multiple decimal points (from group separators that became dots), only the last dot is kept as the decimal separator. This preserves correct handling of German "1.234,5" → "1234.5" and English "1,234.5" → "1234.5" while also allowing English "123,4" → "123.4". Co-authored-by: Chavda Sachin --- src/libs/DistanceRequestUtils.ts | 17 ++++++++++++---- tests/unit/OdometerNormalizationTest.ts | 26 +++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 874079e2639b7..16c060dd7d8cc 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -474,15 +474,24 @@ function isDistanceAmountWithinLimit(distance: number, rate: number): boolean { * Normalize odometer text by standardizing locale digits and stripping all * non-numeric characters except the decimal point. fromLocaleDigit converts * each locale character to its standard equivalent (e.g. German ',' → '.' - * for decimal, German '.' → ',' for group separator), then we keep only - * digits and the standard decimal point. + * for decimal, German '.' → ',' for group separator). Commas are then + * treated as decimal separators so both "." and "," work as decimal input + * in any locale. If this produces multiple decimal points (from group + * separators that became dots), only the last dot is kept as the decimal. + * Finally we keep only digits and the standard decimal point. */ function normalizeOdometerText(text: string, fromLocaleDigit: (char: string) => string): string { const standardized = replaceAllDigits(text, fromLocaleDigit); - const stripped = standardized.replaceAll(/[^0-9.]/g, ''); + // Treat commas as decimal separators so both "." and "," work as decimal input + const withCommasAsDecimals = standardized.replaceAll(',', '.'); + const stripped = withCommasAsDecimals.replaceAll(/[^0-9.]/g, ''); + // If multiple decimal points exist (e.g. from group separators that became + // dots), keep only the last one as the actual decimal separator. + const dotParts = stripped.split('.'); + const consolidated = dotParts.length > 2 ? dotParts.slice(0, -1).join('') + '.' + (dotParts.at(-1) ?? '') : stripped; // Remove redundant leading zeroes (e.g. "007" → "7", "000" → "0") but // keep a single zero before a decimal point (e.g. "0.5" stays "0.5"). - return stripped.replace(/^0+(?=\d)/, ''); + return consolidated.replace(/^0+(?=\d)/, ''); } export default { diff --git a/tests/unit/OdometerNormalizationTest.ts b/tests/unit/OdometerNormalizationTest.ts index 4db2aa4cbb80a..d700688c80c2c 100644 --- a/tests/unit/OdometerNormalizationTest.ts +++ b/tests/unit/OdometerNormalizationTest.ts @@ -9,8 +9,8 @@ describe('normalizeOdometerText', () => { expect(DistanceRequestUtils.normalizeOdometerText('1,5', germanFromLocaleDigit)).toBe('1.5'); }); - it("German '1.5' (fifteen, dot is group separator) normalizes to '15'", () => { - expect(DistanceRequestUtils.normalizeOdometerText('1.5', germanFromLocaleDigit)).toBe('15'); + it("German '1.5' (dot now treated as decimal) normalizes to '1.5'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('1.5', germanFromLocaleDigit)).toBe('1.5'); }); it("German '1.234,5' (1234.5) normalizes to '1234.5'", () => { @@ -38,6 +38,28 @@ describe('normalizeOdometerText', () => { }); }); + describe('comma as decimal separator', () => { + const englishFromLocaleDigit = (char: string) => fromLocaleDigit('en', char); + const germanFromLocaleDigit = (char: string) => fromLocaleDigit('de', char); + + it("English '123,4' (comma as decimal) normalizes to '123.4'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('123,4', englishFromLocaleDigit)).toBe('123.4'); + }); + + it("English '1,234.5' (comma as group with dot decimal) normalizes to '1234.5'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('1,234.5', englishFromLocaleDigit)).toBe('1234.5'); + }); + + it("German '1.234,5' (dot-group, comma-decimal) normalizes to '1234.5'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('1.234,5', germanFromLocaleDigit)).toBe('1234.5'); + }); + + it("plain comma only: ',5' normalizes to '.5'", () => { + const identity = (char: string) => char; + expect(DistanceRequestUtils.normalizeOdometerText(',5', identity)).toBe('.5'); + }); + }); + describe('leading zeroes', () => { const identity = (char: string) => char; From 21e392b97465400f2e8e8a3b7a034008d82bce06 Mon Sep 17 00:00:00 2001 From: "Chavda Sachin (via MelvinBot)" Date: Tue, 17 Mar 2026 17:22:35 +0000 Subject: [PATCH 4/9] Fix: use template literal instead of string concatenation in normalizeOdometerText Co-authored-by: Chavda Sachin --- src/libs/DistanceRequestUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 16c060dd7d8cc..a679bb51345e9 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -488,7 +488,7 @@ function normalizeOdometerText(text: string, fromLocaleDigit: (char: string) => // If multiple decimal points exist (e.g. from group separators that became // dots), keep only the last one as the actual decimal separator. const dotParts = stripped.split('.'); - const consolidated = dotParts.length > 2 ? dotParts.slice(0, -1).join('') + '.' + (dotParts.at(-1) ?? '') : stripped; + const consolidated = dotParts.length > 2 ? `${dotParts.slice(0, -1).join('')}.${dotParts.at(-1) ?? ''}` : stripped; // Remove redundant leading zeroes (e.g. "007" → "7", "000" → "0") but // keep a single zero before a decimal point (e.g. "0.5" stays "0.5"). return consolidated.replace(/^0+(?=\d)/, ''); From fa956ae8520c03d2fe3a23c3f9767dddc7075c4a Mon Sep 17 00:00:00 2001 From: "Chavda Sachin (via MelvinBot)" Date: Tue, 17 Mar 2026 17:24:57 +0000 Subject: [PATCH 5/9] Display odometer input in user's locale format The normalized value was always displayed with '.' as the decimal separator. Now the displayed value uses the locale's decimal separator (e.g. ',' in German) via toLocaleDigit, while internal calculations continue to normalize back to standard format via fromLocaleDigit. Co-authored-by: Chavda Sachin --- .../step/IOURequestStepDistanceOdometer.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 7eeacfa580079..4cf448c39cdae 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -69,7 +69,7 @@ function IOURequestStepDistanceOdometer({ transaction, currentUserPersonalDetails, }: IOURequestStepDistanceOdometerProps) { - const {translate, fromLocaleDigit, numberFormat} = useLocalize(); + const {translate, fromLocaleDigit, toLocaleDigit, numberFormat} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); @@ -186,8 +186,8 @@ function IOURequestStepDistanceOdometer({ } const currentStart = currentTransaction?.comment?.odometerStart; const currentEnd = currentTransaction?.comment?.odometerEnd; - const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; - const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; + const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString().replace('.', toLocaleDigit('.')) : ''; + const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString().replace('.', toLocaleDigit('.')) : ''; initialStartReadingRef.current = startValue; initialEndReadingRef.current = endValue; initialStartImageRef.current = currentTransaction?.comment?.odometerStartImage; @@ -198,6 +198,7 @@ function IOURequestStepDistanceOdometer({ currentTransaction?.comment?.odometerEnd, currentTransaction?.comment?.odometerStartImage, currentTransaction?.comment?.odometerEndImage, + toLocaleDigit, ]); // Initialize values from transaction when editing or when transaction has data (but not when switching tabs) @@ -218,8 +219,8 @@ function IOURequestStepDistanceOdometer({ (hasTransactionData && !hasLocalState && hasInitializedRefs.current); if (shouldInitialize) { - const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; - const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; + const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString().replace('.', toLocaleDigit('.')) : ''; + const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString().replace('.', toLocaleDigit('.')) : ''; if (startValue || endValue) { setStartReading(startValue); @@ -228,7 +229,7 @@ function IOURequestStepDistanceOdometer({ endReadingRef.current = endValue; } } - }, [currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, isEditing]); + }, [currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, isEditing, toLocaleDigit]); // Calculate total distance - updated live after every input change const totalDistance = (() => { @@ -304,13 +305,18 @@ function IOURequestStepDistanceOdometer({ return true; }; + // Convert a standard-format number string to the user's locale format for display + // (e.g. "1.5" → "1,5" in German locale). + const toLocaleFormat = (text: string): string => text.replace('.', toLocaleDigit('.')); + const handleStartReadingChange = (text: string) => { if (!isOdometerInputValid(text, startReading)) { return; } const normalized = DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit); - setStartReading(normalized); - startReadingRef.current = normalized; + const display = toLocaleFormat(normalized); + setStartReading(display); + startReadingRef.current = display; if (formError) { setFormError(''); } @@ -321,8 +327,9 @@ function IOURequestStepDistanceOdometer({ return; } const normalized = DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit); - setEndReading(normalized); - endReadingRef.current = normalized; + const display = toLocaleFormat(normalized); + setEndReading(display); + endReadingRef.current = display; if (formError) { setFormError(''); } From b319c3a12d59196ce54736f61122d521537d9e72 Mon Sep 17 00:00:00 2001 From: "Chavda Sachin (via MelvinBot)" Date: Tue, 17 Mar 2026 17:40:34 +0000 Subject: [PATCH 6/9] Preserve group separators (commas) in odometer display The change handlers were fully normalizing input before displaying it, which stripped commas used as thousands separators (e.g. "1,234.5" became "1234.5"). Now only truly invalid characters (letters, symbols) are stripped for display while commas and periods are preserved. normalizeOdometerText is still used for validation and calculations. Co-authored-by: Chavda Sachin --- .../request/step/IOURequestStepDistanceOdometer.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 4cf448c39cdae..ecdebda1f6adb 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -305,16 +305,14 @@ function IOURequestStepDistanceOdometer({ return true; }; - // Convert a standard-format number string to the user's locale format for display - // (e.g. "1.5" → "1,5" in German locale). - const toLocaleFormat = (text: string): string => text.replace('.', toLocaleDigit('.')); - const handleStartReadingChange = (text: string) => { if (!isOdometerInputValid(text, startReading)) { return; } - const normalized = DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit); - const display = toLocaleFormat(normalized); + // Preserve the user's formatting (commas, periods) for display. + // Strip only truly invalid characters (letters, symbols). + // normalizeOdometerText is still used internally for validation and calculations. + const display = text.replaceAll(/[^0-9.,]/g, '').replace(/^0+(?=\d)/, ''); setStartReading(display); startReadingRef.current = display; if (formError) { @@ -326,8 +324,7 @@ function IOURequestStepDistanceOdometer({ if (!isOdometerInputValid(text, endReading)) { return; } - const normalized = DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit); - const display = toLocaleFormat(normalized); + const display = text.replaceAll(/[^0-9.,]/g, '').replace(/^0+(?=\d)/, ''); setEndReading(display); endReadingRef.current = display; if (formError) { From 9232e82e9140d38382189201fbd4dd576e82edae Mon Sep 17 00:00:00 2001 From: "Chavda Sachin (via MelvinBot)" Date: Tue, 17 Mar 2026 17:59:56 +0000 Subject: [PATCH 7/9] Enforce locale-strict decimal separator in odometer input The decimal separator is now strictly determined by the user's preferred locale. In English locale, only '.' works as decimal (commas are stripped as group separators). In German locale, only ',' works as decimal (dots are stripped as group separators). Previously normalizeOdometerText treated all commas as decimal separators regardless of locale, so English "12,3" was incorrectly converted to "12.3". Now after fromLocaleDigit standardization, commas are always group separators and are stripped. The display handlers also filter input to only allow digits and the locale's decimal separator character. Co-authored-by: Chavda Sachin --- src/libs/DistanceRequestUtils.ts | 18 +++++------------- .../step/IOURequestStepDistanceOdometer.tsx | 18 +++++++++++++----- tests/unit/OdometerNormalizationTest.ts | 14 +++++++------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index a679bb51345e9..9d1a2d54e2768 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -474,24 +474,16 @@ function isDistanceAmountWithinLimit(distance: number, rate: number): boolean { * Normalize odometer text by standardizing locale digits and stripping all * non-numeric characters except the decimal point. fromLocaleDigit converts * each locale character to its standard equivalent (e.g. German ',' → '.' - * for decimal, German '.' → ',' for group separator). Commas are then - * treated as decimal separators so both "." and "," work as decimal input - * in any locale. If this produces multiple decimal points (from group - * separators that became dots), only the last dot is kept as the decimal. - * Finally we keep only digits and the standard decimal point. + * for decimal, German '.' → ',' for group separator), so after conversion + * dots are always decimals and commas are always group separators. + * We then strip everything except digits and the standard decimal point. */ function normalizeOdometerText(text: string, fromLocaleDigit: (char: string) => string): string { const standardized = replaceAllDigits(text, fromLocaleDigit); - // Treat commas as decimal separators so both "." and "," work as decimal input - const withCommasAsDecimals = standardized.replaceAll(',', '.'); - const stripped = withCommasAsDecimals.replaceAll(/[^0-9.]/g, ''); - // If multiple decimal points exist (e.g. from group separators that became - // dots), keep only the last one as the actual decimal separator. - const dotParts = stripped.split('.'); - const consolidated = dotParts.length > 2 ? `${dotParts.slice(0, -1).join('')}.${dotParts.at(-1) ?? ''}` : stripped; + const stripped = standardized.replaceAll(/[^0-9.]/g, ''); // Remove redundant leading zeroes (e.g. "007" → "7", "000" → "0") but // keep a single zero before a decimal point (e.g. "0.5" stays "0.5"). - return consolidated.replace(/^0+(?=\d)/, ''); + return stripped.replace(/^0+(?=\d)/, ''); } export default { diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index ecdebda1f6adb..35b1baafb1397 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -309,10 +309,13 @@ function IOURequestStepDistanceOdometer({ if (!isOdometerInputValid(text, startReading)) { return; } - // Preserve the user's formatting (commas, periods) for display. - // Strip only truly invalid characters (letters, symbols). - // normalizeOdometerText is still used internally for validation and calculations. - const display = text.replaceAll(/[^0-9.,]/g, '').replace(/^0+(?=\d)/, ''); + // Only allow digits and the locale's decimal separator (e.g. '.' for English, ',' for German). + const localeDecimal = toLocaleDigit('.'); + const display = text + .split('') + .filter((c) => /\d/.test(c) || c === localeDecimal) + .join('') + .replace(/^0+(?=\d)/, ''); setStartReading(display); startReadingRef.current = display; if (formError) { @@ -324,7 +327,12 @@ function IOURequestStepDistanceOdometer({ if (!isOdometerInputValid(text, endReading)) { return; } - const display = text.replaceAll(/[^0-9.,]/g, '').replace(/^0+(?=\d)/, ''); + const localeDecimal = toLocaleDigit('.'); + const display = text + .split('') + .filter((c) => /\d/.test(c) || c === localeDecimal) + .join('') + .replace(/^0+(?=\d)/, ''); setEndReading(display); endReadingRef.current = display; if (formError) { diff --git a/tests/unit/OdometerNormalizationTest.ts b/tests/unit/OdometerNormalizationTest.ts index d700688c80c2c..632e29a2d1429 100644 --- a/tests/unit/OdometerNormalizationTest.ts +++ b/tests/unit/OdometerNormalizationTest.ts @@ -9,8 +9,8 @@ describe('normalizeOdometerText', () => { expect(DistanceRequestUtils.normalizeOdometerText('1,5', germanFromLocaleDigit)).toBe('1.5'); }); - it("German '1.5' (dot now treated as decimal) normalizes to '1.5'", () => { - expect(DistanceRequestUtils.normalizeOdometerText('1.5', germanFromLocaleDigit)).toBe('1.5'); + it("German '1.5' (dot is group separator, fifteen) normalizes to '15'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('1.5', germanFromLocaleDigit)).toBe('15'); }); it("German '1.234,5' (1234.5) normalizes to '1234.5'", () => { @@ -38,12 +38,12 @@ describe('normalizeOdometerText', () => { }); }); - describe('comma as decimal separator', () => { + describe('locale-strict decimal handling', () => { const englishFromLocaleDigit = (char: string) => fromLocaleDigit('en', char); const germanFromLocaleDigit = (char: string) => fromLocaleDigit('de', char); - it("English '123,4' (comma as decimal) normalizes to '123.4'", () => { - expect(DistanceRequestUtils.normalizeOdometerText('123,4', englishFromLocaleDigit)).toBe('123.4'); + it("English '123,4' (comma is group separator, not decimal) normalizes to '1234'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('123,4', englishFromLocaleDigit)).toBe('1234'); }); it("English '1,234.5' (comma as group with dot decimal) normalizes to '1234.5'", () => { @@ -54,9 +54,9 @@ describe('normalizeOdometerText', () => { expect(DistanceRequestUtils.normalizeOdometerText('1.234,5', germanFromLocaleDigit)).toBe('1234.5'); }); - it("plain comma only: ',5' normalizes to '.5'", () => { + it("plain comma with identity function: ',5' normalizes to '5' (comma stripped)", () => { const identity = (char: string) => char; - expect(DistanceRequestUtils.normalizeOdometerText(',5', identity)).toBe('.5'); + expect(DistanceRequestUtils.normalizeOdometerText(',5', identity)).toBe('5'); }); }); From a9415e3eba2e27c1b70fae42b9a00136ecf97d9b Mon Sep 17 00:00:00 2001 From: "Chavda Sachin (via MelvinBot)" Date: Tue, 17 Mar 2026 21:12:59 +0000 Subject: [PATCH 8/9] Use prepareTextForDisplay for odometer input normalization Replace inline locale-aware filtering with a dedicated prepareTextForDisplay utility (per d6c6114). The function strips non-numeric characters (keeping decimal point and comma) and removes redundant leading zeroes. Also adds unit tests for both prepareTextForDisplay and normalizeOdometerText. Co-authored-by: Chavda Sachin --- src/libs/DistanceRequestUtils.ts | 9 ++++ .../step/IOURequestStepDistanceOdometer.tsx | 36 ++++++---------- tests/unit/OdometerNormalizationTest.ts | 42 +++++++++++++++++++ 3 files changed, 63 insertions(+), 24 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 9d1a2d54e2768..aaad9bbb26c5a 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -486,6 +486,14 @@ function normalizeOdometerText(text: string, fromLocaleDigit: (char: string) => return stripped.replace(/^0+(?=\d)/, ''); } +/** + * Prepare odometer input text for display by removing non-numeric characters + * (except the decimal point and comma) and stripping redundant leading zeroes. + */ +function prepareTextForDisplay(text: string): string { + return text.replaceAll(/[^0-9.,]/g, '').replace(/^0+(?=\d)/, ''); +} + export default { getDefaultMileageRate, getDistanceMerchant, @@ -507,6 +515,7 @@ export default { getRateForExpenseDisplay, isDistanceAmountWithinLimit, normalizeOdometerText, + prepareTextForDisplay, }; export type {MileageRate}; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 35b1baafb1397..79342e1404f6a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -69,7 +69,7 @@ function IOURequestStepDistanceOdometer({ transaction, currentUserPersonalDetails, }: IOURequestStepDistanceOdometerProps) { - const {translate, fromLocaleDigit, toLocaleDigit, numberFormat} = useLocalize(); + const {translate, fromLocaleDigit, numberFormat} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); @@ -186,8 +186,8 @@ function IOURequestStepDistanceOdometer({ } const currentStart = currentTransaction?.comment?.odometerStart; const currentEnd = currentTransaction?.comment?.odometerEnd; - const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString().replace('.', toLocaleDigit('.')) : ''; - const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString().replace('.', toLocaleDigit('.')) : ''; + const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; + const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; initialStartReadingRef.current = startValue; initialEndReadingRef.current = endValue; initialStartImageRef.current = currentTransaction?.comment?.odometerStartImage; @@ -198,7 +198,6 @@ function IOURequestStepDistanceOdometer({ currentTransaction?.comment?.odometerEnd, currentTransaction?.comment?.odometerStartImage, currentTransaction?.comment?.odometerEndImage, - toLocaleDigit, ]); // Initialize values from transaction when editing or when transaction has data (but not when switching tabs) @@ -219,8 +218,8 @@ function IOURequestStepDistanceOdometer({ (hasTransactionData && !hasLocalState && hasInitializedRefs.current); if (shouldInitialize) { - const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString().replace('.', toLocaleDigit('.')) : ''; - const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString().replace('.', toLocaleDigit('.')) : ''; + const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; + const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; if (startValue || endValue) { setStartReading(startValue); @@ -229,7 +228,7 @@ function IOURequestStepDistanceOdometer({ endReadingRef.current = endValue; } } - }, [currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, isEditing, toLocaleDigit]); + }, [currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, isEditing]); // Calculate total distance - updated live after every input change const totalDistance = (() => { @@ -309,15 +308,9 @@ function IOURequestStepDistanceOdometer({ if (!isOdometerInputValid(text, startReading)) { return; } - // Only allow digits and the locale's decimal separator (e.g. '.' for English, ',' for German). - const localeDecimal = toLocaleDigit('.'); - const display = text - .split('') - .filter((c) => /\d/.test(c) || c === localeDecimal) - .join('') - .replace(/^0+(?=\d)/, ''); - setStartReading(display); - startReadingRef.current = display; + const textForDisplay = DistanceRequestUtils.prepareTextForDisplay(text); + setStartReading(textForDisplay); + startReadingRef.current = textForDisplay; if (formError) { setFormError(''); } @@ -327,14 +320,9 @@ function IOURequestStepDistanceOdometer({ if (!isOdometerInputValid(text, endReading)) { return; } - const localeDecimal = toLocaleDigit('.'); - const display = text - .split('') - .filter((c) => /\d/.test(c) || c === localeDecimal) - .join('') - .replace(/^0+(?=\d)/, ''); - setEndReading(display); - endReadingRef.current = display; + const textForDisplay = DistanceRequestUtils.prepareTextForDisplay(text); + setEndReading(textForDisplay); + endReadingRef.current = textForDisplay; if (formError) { setFormError(''); } diff --git a/tests/unit/OdometerNormalizationTest.ts b/tests/unit/OdometerNormalizationTest.ts index 632e29a2d1429..72b9fc40bab07 100644 --- a/tests/unit/OdometerNormalizationTest.ts +++ b/tests/unit/OdometerNormalizationTest.ts @@ -88,3 +88,45 @@ describe('normalizeOdometerText', () => { }); }); }); + +describe('prepareTextForDisplay', () => { + it("strips non-numeric characters except decimal and comma: 'abc123.4def' → '123.4'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('abc123.4def')).toBe('123.4'); + }); + + it("keeps commas for locale display: '1,234.5' → '1,234.5'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('1,234.5')).toBe('1,234.5'); + }); + + it("strips leading zeroes: '007' → '7'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('007')).toBe('7'); + }); + + it("strips redundant leading zeroes: '000' → '0'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('000')).toBe('0'); + }); + + it("keeps single zero before decimal: '0.5' → '0.5'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('0.5')).toBe('0.5'); + }); + + it("normalizes multiple leading zeroes before decimal: '00.5' → '0.5'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('00.5')).toBe('0.5'); + }); + + it("keeps single zero: '0' → '0'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('0')).toBe('0'); + }); + + it("strips letters and symbols: '@#$%123' → '123'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('@#$%123')).toBe('123'); + }); + + it("handles empty string: '' → ''", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('')).toBe(''); + }); + + it("handles valid number unchanged: '12345.6' → '12345.6'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('12345.6')).toBe('12345.6'); + }); +}); From 9ed5e419b511464a287c11ca84681cfe22917984 Mon Sep 17 00:00:00 2001 From: "Chavda Sachin (via MelvinBot)" Date: Tue, 17 Mar 2026 21:31:35 +0000 Subject: [PATCH 9/9] Allow space as group separator in prepareTextForDisplay Update the regex in prepareTextForDisplay to allow blank spaces alongside commas and periods as group separators (e.g. French locale uses "1 234,5"). Added tests for French (space) and Italian (period) group separators. Updated QA steps to cover en, it, and fr locales. Co-authored-by: Chavda Sachin --- src/libs/DistanceRequestUtils.ts | 5 +++-- tests/unit/OdometerNormalizationTest.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index aaad9bbb26c5a..9216018e1f6a9 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -488,10 +488,11 @@ function normalizeOdometerText(text: string, fromLocaleDigit: (char: string) => /** * Prepare odometer input text for display by removing non-numeric characters - * (except the decimal point and comma) and stripping redundant leading zeroes. + * (except the decimal point, comma, and space — which serve as group or + * decimal separators depending on locale) and stripping redundant leading zeroes. */ function prepareTextForDisplay(text: string): string { - return text.replaceAll(/[^0-9.,]/g, '').replace(/^0+(?=\d)/, ''); + return text.replaceAll(/[^0-9., ]/g, '').replace(/^0+(?=\d)/, ''); } export default { diff --git a/tests/unit/OdometerNormalizationTest.ts b/tests/unit/OdometerNormalizationTest.ts index 72b9fc40bab07..e558ef5ff5db6 100644 --- a/tests/unit/OdometerNormalizationTest.ts +++ b/tests/unit/OdometerNormalizationTest.ts @@ -129,4 +129,12 @@ describe('prepareTextForDisplay', () => { it("handles valid number unchanged: '12345.6' → '12345.6'", () => { expect(DistanceRequestUtils.prepareTextForDisplay('12345.6')).toBe('12345.6'); }); + + it("keeps spaces as group separator (French locale): '1 234,5' → '1 234,5'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('1 234,5')).toBe('1 234,5'); + }); + + it("keeps periods as group separator (Italian locale): '1.234,5' → '1.234,5'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('1.234,5')).toBe('1.234,5'); + }); });