Skip to content

Commit 70581ac

Browse files
committed
fix(tables): let the separator decide a lone-separator amount, not the marker
Greptile blocked on `$1.234` reading as 1234, and offered the alternative of stating the input contract explicitly. Looking for a precedent settled it against me: a spreadsheet with a USD-formatted cell stores a typed `1.234` as 1.234 and displays $1.23. Reinterpreting it as one thousand two hundred thirty-four is a thousandfold surprise, and my defense of it was wrong. The rule now keys on the separator, tempered by what the currency can express: - A dot is the decimal point in the notation most people type, so it stays a decimal — except for a currency with no decimal places, where `1.235 ¥` cannot be a fraction of a yen and is the one lone-separator form a formatter actually emits. - A comma is grouping by convention (`1,500` is fifteen hundred), except for a three-decimal currency, where `0,500` is a half. A currency with one or two decimal places always formats with both separators, so no formatter output is decided here at all. This also drops the `hadMarker` signal entirely — once the separator and the currency answer the question, where the string came from stops mattering.
1 parent fa775c5 commit 70581ac

2 files changed

Lines changed: 29 additions & 26 deletions

File tree

apps/sim/lib/table/__tests__/currency.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,18 @@ describe('parseCurrencyInput', () => {
178178
// currencies produce `1.235 ¥`. And a three-decimal currency's trailing
179179
// three digits are decimals however the value arrived, which matters for
180180
// CSV import, where nothing carries a marker.
181+
// A dot stays the decimal point: typing `1.234` in a USD cell means 1.234,
182+
// which the display rounds to $1.23 exactly as a spreadsheet does. Reading
183+
// it as 1,234 would be a thousandfold surprise.
184+
expect(parseCurrencyInput('$1.234', 'USD')).toBe(1.234)
185+
expect(parseCurrencyInput('1.234')).toBe(1.234)
186+
// Except where the currency has no decimals — the one lone-separator form
187+
// a formatter actually emits.
181188
expect(parseCurrencyInput('1.235 ¥', 'JPY')).toBe(1235)
189+
// A comma is grouping by convention, except for three-decimal currencies.
190+
expect(parseCurrencyInput('1,500', 'USD')).toBe(1500)
182191
expect(parseCurrencyInput('0,500', 'KWD')).toBe(0.5)
183192
expect(parseCurrencyInput('12,000', 'TND')).toBe(12)
184-
expect(parseCurrencyInput('1,500', 'USD')).toBe(1500)
185-
expect(parseCurrencyInput('1.234')).toBe(1.234)
186193
})
187194

188195
it('refuses a scale suffix rather than shrinking the value', () => {

apps/sim/lib/table/currency.ts

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,6 @@ export function parseCurrencyInput(raw: unknown, currencyCode?: string): number
245245
// rejected below.
246246
const withoutPrefix = body.replace(CURRENCY_MARKER_PREFIX, '$1')
247247
const withoutMarkers = withoutPrefix.replace(CURRENCY_MARKER_SUFFIX, '')
248-
// Whether a marker was present at all. A marked string came from a
249-
// formatter; a bare one was typed by a person. That distinguishes the two
250-
// readings of a lone separator followed by three digits, below.
251-
const hadMarker = withoutMarkers !== body
252248
const cleaned = withoutMarkers.replace(/[\s\u00a0\u202f\u2019']/gu, '')
253249
// What remains must be ONLY digits and separators. Anything else — a
254250
// US-format date, leftover prose — is not an amount.
@@ -270,8 +266,7 @@ export function parseCurrencyInput(raw: unknown, currencyCode?: string): number
270266
normalized = `${integerPart.split(groupSeparator).join('')}.${decimalParts[0]}`
271267
} else if (lastComma !== -1) {
272268
const repeated = digitsAndSeps.indexOf(',') !== lastComma
273-
const grouping =
274-
repeated || loneSeparatorIsGrouping(digitsAndSeps, ',', hadMarker, currencyCode)
269+
const grouping = repeated || loneSeparatorIsGrouping(digitsAndSeps, ',', currencyCode)
275270
if (grouping) {
276271
if (!hasValidGrouping(digitsAndSeps, ',')) return null
277272
normalized = digitsAndSeps.split(',').join('')
@@ -280,8 +275,7 @@ export function parseCurrencyInput(raw: unknown, currencyCode?: string): number
280275
}
281276
} else if (lastDot !== -1) {
282277
const repeated = digitsAndSeps.indexOf('.') !== lastDot
283-
const grouping =
284-
repeated || loneSeparatorIsGrouping(digitsAndSeps, '.', hadMarker, currencyCode)
278+
const grouping = repeated || loneSeparatorIsGrouping(digitsAndSeps, '.', currencyCode)
285279
if (grouping) {
286280
if (!hasValidGrouping(digitsAndSeps, '.')) return null
287281
normalized = digitsAndSeps.split('.').join('')
@@ -299,30 +293,32 @@ export function parseCurrencyInput(raw: unknown, currencyCode?: string): number
299293

300294
/**
301295
* Whether a lone separator followed by exactly three digits is grouping rather
302-
* than a decimal point — `1.235 ¥` is one thousand two hundred thirty-five yen,
303-
* while a typed `1.235` is one and a bit.
296+
* than a decimal point.
304297
*
305-
* Two signals resolve it. A marker means the string came from a formatter, and
306-
* formatters group; a bare string was typed by a person, who meant decimals.
307-
* And a currency with three decimal places (KWD, TND) legitimately ends in
308-
* three digits after its separator, so for those the reading is always decimal.
298+
* The separator decides, tempered by what the currency can express:
299+
*
300+
* - A **dot** is the decimal point in the notation most people type, so it
301+
* stays a decimal — typing `1.234` in a USD cell means 1.234, which the
302+
* display then rounds to `$1.23`, exactly as a spreadsheet does. Reading it
303+
* as 1,234 would be a thousandfold surprise. The exception is a currency with
304+
* no decimal places at all: `1.235 ¥` cannot be a fraction of a yen, and is
305+
* the one lone-separator form a formatter actually emits.
306+
* - A **comma** is grouping by convention — `1,500` is fifteen hundred, not one
307+
* and a half. The exception mirrors the first: a currency with three decimal
308+
* places (KWD, TND) legitimately ends in three digits after its separator,
309+
* so `0,500` is a half.
310+
*
311+
* A currency with one or two decimal places always formats with BOTH
312+
* separators, so no formatter output is decided here.
309313
*/
310314
function loneSeparatorIsGrouping(
311315
digitsAndSeps: string,
312316
separator: string,
313-
hadMarker: boolean,
314317
currencyCode: string | undefined
315318
): boolean {
316319
if (!new RegExp(`\\${separator}\\d{3}$`).test(digitsAndSeps)) return false
317-
// A currency with three decimal places (KWD, TND) legitimately ends in three
318-
// digits after its separator, so for those the reading is always decimal —
319-
// regardless of how the value arrived, since a CSV import carries no marker.
320-
if (currencyFractionDigits(currencyCode) === 3) return false
321-
// A comma tail of exactly three digits is grouping by convention (`1,500`).
322-
if (separator === ',') return true
323-
// A dot is the decimal point in the notation most users type, so it only
324-
// reads as grouping when a marker shows a formatter produced the string.
325-
return hadMarker
320+
const fractionDigits = currencyFractionDigits(currencyCode)
321+
return separator === ',' ? fractionDigits !== 3 : fractionDigits === 0
326322
}
327323

328324
/** A currency's conventional decimal places, defaulting to 2 when unknown. */

0 commit comments

Comments
 (0)