From 88cb7f22dd1cbafd48d5884813d092818b283411 Mon Sep 17 00:00:00 2001 From: Konstantinos Thermos Date: Tue, 21 Jul 2026 21:36:47 +0300 Subject: [PATCH] fix(budget): match the add-expense input decimal separator to the list (#1645) --- .../src/components/Budget/CostsPanel.test.tsx | 18 +++++++++--------- client/src/components/Budget/CostsPanel.tsx | 14 +++++++------- .../mobile/screens/trip/sheets/MCostSheet.tsx | 18 +++++++++--------- client/src/utils/formatters.test.ts | 16 +++++++++++++++- client/src/utils/formatters.ts | 14 ++++++++++++++ 5 files changed, 54 insertions(+), 26 deletions(-) diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 9ebec4544..94869e274 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -108,12 +108,12 @@ describe('CostsPanel — settlements in the ledger', () => { await user.click(await screen.findByRole('button', { name: 'Add expense' })) await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner') - const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[] + const nums = () => screen.getAllByPlaceholderText('0,00') as HTMLInputElement[] await user.type(nums()[0], '100') // total = 100 await user.click(screen.getByRole('button', { name: /Custom/i })) - const customInputs = screen.getAllByPlaceholderText('50.00') + const customInputs = screen.getAllByPlaceholderText('50,00') await user.type(customInputs[0], '30') await user.type(customInputs[1], '70') @@ -146,7 +146,7 @@ describe('CostsPanel — settlements in the ledger', () => { await user.click(await screen.findByRole('button', { name: 'Add expense' })) await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags') - await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99 + await user.type(screen.getAllByPlaceholderText('0,00')[0], '39,99') // comma → normalized to 39.99 const addBtns = screen.getAllByRole('button', { name: 'Add expense' }) await user.click(addBtns[addBtns.length - 1]) // footer submit @@ -203,7 +203,7 @@ describe('CostsPanel — settlements in the ledger', () => { await user.click(await screen.findByRole('button', { name: 'Add expense' })) await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel') - await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later + await user.type(screen.getAllByPlaceholderText('0,00')[0], '120') // total only, paid on-site later // Deselect everyone — the cost is recorded without a split (the bug: this was blocked). // The participant toggles are buttons; the same names also appear as plain text in @@ -292,14 +292,14 @@ describe('CostsPanel — settlements in the ledger', () => { await user.click(await screen.findByRole('button', { name: 'Add expense' })) await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner') - await user.type(screen.getAllByPlaceholderText('0.00')[0], '90') + await user.type(screen.getAllByPlaceholderText('0,00')[0], '90') await user.click(screen.getByRole('button', { name: 'Multiple people paid' })) // Alice (me) is seeded as the sole payer; including Bob rebalances to 45/45. await user.click(screen.getAllByTestId('payer-toggle')[1]) expect(screen.getAllByTestId('payer-amount').map(i => (i as HTMLInputElement).value)) - .toEqual(['45.00', '45.00']) + .toEqual(['45,00', '45,00']) const addBtns = screen.getAllByRole('button', { name: 'Add expense' }) await user.click(addBtns[addBtns.length - 1]) @@ -330,7 +330,7 @@ describe('CostsPanel — settlements in the ledger', () => { await user.click(await screen.findByRole('button', { name: 'Add expense' })) await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner') - await user.type(screen.getAllByPlaceholderText('0.00')[0], '90') + await user.type(screen.getAllByPlaceholderText('0,00')[0], '90') await user.click(screen.getByRole('button', { name: 'Multiple people paid' })) await user.click(screen.getAllByTestId('payer-toggle')[1]) @@ -435,7 +435,7 @@ describe('CostsPanel — settlements in the ledger', () => { await user.click(addBtn) const itemNames = screen.getAllByPlaceholderText('Item name') - const itemPrices = screen.getAllByPlaceholderText('0.00') + const itemPrices = screen.getAllByPlaceholderText('0,00') await user.type(itemNames[0], 'Apples') await user.type(itemPrices[1], '10') @@ -448,7 +448,7 @@ describe('CostsPanel — settlements in the ledger', () => { await user.type(itemNames[2], 'Milk') await user.type(itemPrices[3], '40') - expect(screen.getByDisplayValue('100.00')).toBeDisabled() + expect(screen.getByDisplayValue('100,00')).toBeDisabled() expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument() expect(screen.getByText(/75\.00/)).toBeInTheDocument() diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 7bbaf6c93..edc2723b3 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n' import { budgetApi } from '../../api/client' import { useExchangeRates } from '../../hooks/useExchangeRates' import { useIsMobile } from '../../hooks/useIsMobile' -import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/formatters' +import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { CustomDatePicker } from '../shared/CustomDateTimePicker' @@ -1295,7 +1295,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{sym(currency)} - @@ -1378,8 +1378,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo {on ? (
{sym(currency)} - onPayerAmountChange(p.id, v)} className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, padding: '8px 0', textAlign: 'right' }} /> @@ -1443,8 +1443,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo {sym(currency)} handleUpdateItemPrice(item.id, v)} className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }} @@ -1526,7 +1526,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo on ? (
{sym(currency)} - handleCustomAmountChange(p.id, e.target.value)} className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
diff --git a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx index a9a19dac6..57271b486 100644 --- a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx @@ -9,7 +9,7 @@ import { useTranslation } from '../../../../i18n' import { useToast } from '../../../../components/shared/Toast' import { useTripStore } from '../../../../store/tripStore' import { useExchangeRates } from '../../../../hooks/useExchangeRates' -import { formatMoney } from '../../../../utils/formatters' +import { formatMoney, localizeAmountInput } from '../../../../utils/formatters' import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants' import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories' import { calculateTicketShares, splitEqualShares, type TicketItem, type ExpensePrefill } from '../../../../components/Budget/CostsPanel' @@ -350,8 +350,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, {sym(currency)} {sym(currency)} onPayerAmountChange(p.id, v)} className="w-full border-0 bg-transparent py-[7px] text-right text-[0.8125rem] font-semibold text-m-ink outline-none" /> @@ -497,8 +497,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, {sym(currency)} handleUpdateItemPrice(item.id, v)} className="w-full border-0 bg-transparent py-[7px] text-right text-[0.8125rem] font-semibold text-m-ink outline-none" /> @@ -575,8 +575,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, handleCustomAmountChange(p.id, e.target.value)} className="w-full border-0 bg-transparent py-[7px] text-right text-[0.8125rem] font-semibold text-m-ink outline-none placeholder:text-m-faint" /> diff --git a/client/src/utils/formatters.test.ts b/client/src/utils/formatters.test.ts index 04a8a6eb9..bc94ef5b5 100644 --- a/client/src/utils/formatters.test.ts +++ b/client/src/utils/formatters.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { splitReservationDateTime, resolveDayId, formatMoney, formatMoneySum, currencyDecimals } from './formatters' +import { splitReservationDateTime, resolveDayId, formatMoney, formatMoneySum, currencyDecimals, localizeAmountInput } from './formatters' import { CURRENCIES, SYMBOLS, currenciesWith } from '../components/Budget/BudgetPanel.constants' import type { Day } from '../types' @@ -26,6 +26,20 @@ describe('resolveDayId', () => { }) }) +describe('localizeAmountInput (#1624)', () => { + it('shows the amount with the currency comma separator so the field matches the list', () => { + expect(localizeAmountInput('12.5', 'EUR')).toBe('12,5') + expect(localizeAmountInput('0.00', 'EUR')).toBe('0,00') + }) + it('keeps the dot for dot-separator currencies', () => { + expect(localizeAmountInput('12.5', 'USD')).toBe('12.5') + }) + it('passes an empty/nullish value through unchanged', () => { + expect(localizeAmountInput('', 'EUR')).toBe('') + expect(localizeAmountInput(null, 'EUR')).toBe('') + }) +}) + describe('KGS currency (#1400)', () => { it('is selectable everywhere the shared currency list feeds', () => { expect(CURRENCIES).toContain('KGS') diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index a4b5b0237..91be2c9ee 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -71,6 +71,20 @@ export function currencyLocale(currency: string): string { return CURRENCY_LOCALE[(currency || '').toUpperCase()] || 'en-US' } +// The decimal separator formatMoney renders a currency with (',' for EUR, '.' for +// USD) — same home-locale convention, so an amount input can match the displayed list. +export function currencyDecimalSeparator(currency: string): string { + return (0.1).toLocaleString(currencyLocale(currency)).replace(/\d/g, '') || '.' +} + +// Show a dot-normalized amount string in an input using the currency's decimal +// separator, so the field reads the same way as the list. State stays dot-normalized; +// callers still normalize typed input back to a dot on change. +export function localizeAmountInput(value: string | number | null | undefined, currency: string): string { + const s = String(value ?? '') + return currencyDecimalSeparator(currency) === ',' ? s.replace('.', ',') : s +} + /** * Locale- and currency-correct money formatting via Intl: the symbol position, * thousands/decimal separators and decimal count all follow the user's locale