fix: balance adjustment silently did nothing on a decimal comma
Pressing Apply on Settings -> Adjust balance did nothing at all: no error, no
request, nothing in the API logs. The cause is in code untouched since
062ba52 (2026-07-01), so this predates the recent releases — it just needs
the right input to show itself.
The form only renders when adjustingAccount?.id === account.id, so
adjustingAccount is never null while Apply is visible. That leaves exactly
one way for the handler to return silently:
if (!adjustingAccount || !adjustTargetBalance) return
i.e. adjustTargetBalance === "". And the field was <input type="number">,
which reports value === "" for anything the browser judges invalid —
including a decimal comma. Every amount in this app is rendered with
Intl.NumberFormat('de-DE'), so typing 1234,56 is the natural thing to do, and
it left the state empty and the guard swallowed the click.
Fixes both halves:
- The input is now type="text" inputMode="decimal", so what the user types
actually reaches the handler.
- The guard reports "Enter a target balance, e.g. 1234.56" instead of
returning silently. A control that does nothing when pressed is the worst
possible failure mode.
Adds parseDecimal() alongside parseAmount() in lib/utils.ts. Balance targets
differ from expense rows: zero and negative are legitimate, so parseAmount's
n > 0 rule would have been wrong here.
parseDecimal is also stricter than the parseFloat(s.replace(',', '.')) idiom
used across the flows, in two ways that matter on a balance field:
- "1.000,50" parsed as 1 under the old idiom — a silent 1000x error. It now
reads the rightmost separator as the decimal point, giving 1000.50, and
handles "1,000.50" the same way.
- "12.5abc" quietly became 12.5 under parseFloat. Number() rejects it.
parseAmount is redefined on top of parseDecimal, so the batch expense grid
picks up both improvements.
Verified: tsc + vite build clean; the 60 backend tests still pass (untouched);
parser checked against .-decimal, ,-decimal, both-separator, negative, zero,
whitespace and trailing-garbage inputs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
This commit is contained in:
1 parent
43c90dcd43
commit
548d640231
2 files changed
+48
-8
No files matched your search
+37
-4
@@ -10,9 +10,42 @@ export function todayLocalISO(): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Accepts both 12.40 and the German 12,40. Returns null for anything that is
|
||||
// not a usable positive amount, so callers can report which row is wrong.
|
||||
// Accepts both 12.40 and the German 12,40, which is what the de-DE formatting
|
||||
// used throughout the app trains you to type. Returns null when the input is
|
||||
// not a number at all. Zero and negatives are valid — a target balance or a
|
||||
// payable can legitimately be either.
|
||||
//
|
||||
// Pair this with <input type="text" inputMode="decimal">, never
|
||||
// <input type="number">: a number input silently reports value === "" for
|
||||
// anything it considers invalid, including a decimal comma, so the bad input
|
||||
// never reaches the parser and the caller sees an empty field instead.
|
||||
export function parseDecimal(input: string): number | null {
|
||||
const t = input.trim()
|
||||
if (t === '') return null
|
||||
|
||||
const lastComma = t.lastIndexOf(',')
|
||||
const lastDot = t.lastIndexOf('.')
|
||||
let normalised: string
|
||||
if (lastComma !== -1 && lastDot !== -1) {
|
||||
// Both separators present, so the rightmost one is the decimal point and
|
||||
// the other is thousands grouping. Without this, "1.000,50" would parse
|
||||
// as 1 — a silent 1000x error on a balance field.
|
||||
normalised = lastComma > lastDot
|
||||
? t.replace(/\./g, '').replace(',', '.')
|
||||
: t.replace(/,/g, '')
|
||||
} else {
|
||||
normalised = t.replace(',', '.')
|
||||
}
|
||||
|
||||
// Number() rather than parseFloat(): parseFloat stops at the first bad
|
||||
// character, so "12.5abc" would quietly become 12.5.
|
||||
const n = Number(normalised)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
// Same, but rejects zero and negatives — for fields that must be a positive
|
||||
// amount, so callers can report which row is wrong.
|
||||
export function parseAmount(input: string): number | null {
|
||||
const n = parseFloat(input.replace(',', '.'))
|
||||
return Number.isFinite(n) && n > 0 ? n : null
|
||||
const n = parseDecimal(input)
|
||||
return n !== null && n > 0 ? n : null
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { api, Account, Category } from '@/api/client'
|
||||
import { parseDecimal } from '@/lib/utils'
|
||||
|
||||
export default function Settings() {
|
||||
const navigate = useNavigate()
|
||||
@@ -90,10 +91,16 @@ export default function Settings() {
|
||||
}
|
||||
|
||||
const saveAdjustBalance = async () => {
|
||||
if (!adjustingAccount || !adjustTargetBalance) return
|
||||
if (!adjustingAccount) return
|
||||
// Zero and negative targets are legitimate, so parseDecimal, not parseAmount.
|
||||
const target = parseDecimal(adjustTargetBalance)
|
||||
if (target === null) {
|
||||
setMutationError('Enter a target balance, e.g. 1234.56')
|
||||
return
|
||||
}
|
||||
setMutationError(null)
|
||||
try {
|
||||
await api.adjustAccountBalance(adjustingAccount.id, parseFloat(adjustTargetBalance))
|
||||
await api.adjustAccountBalance(adjustingAccount.id, target)
|
||||
setAdjustingAccount(null)
|
||||
setAdjustTargetBalance('')
|
||||
window.dispatchEvent(new CustomEvent('fin:transaction-added'))
|
||||
@@ -263,8 +270,8 @@ export default function Settings() {
|
||||
value={adjustTargetBalance}
|
||||
onChange={e => setAdjustTargetBalance(e.target.value)}
|
||||
placeholder="Target balance"
|
||||
type="number"
|
||||
step="0.01"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
|
||||
Reference in new issue
Block a user