From 0c0b8e8059d41a853a107c3bd61354c3f9d8c0df Mon Sep 17 00:00:00 2001 From: marcus06 Date: Wed, 17 Jun 2026 15:34:03 +0200 Subject: [PATCH] fix(tui): accept spend limit input in dollars (#56) Spend-limit input now takes dollars (e.g. 25.50) instead of raw cents. Parsed to integer cents as dollars*100 + cents (no float multiply) so an amount like 50.01 never loses a cent to binary rounding; the persisted column and API still use cents. Current limit and prefill render via formatCentsDollars. Tests updated to the dollar grammar. Co-authored-by: Claude Opus 4.8 --- pkg/tui/account.go | 72 +++++++++++++++++++++++++++++------------ pkg/tui/account_test.go | 22 +++++++------ 2 files changed, 65 insertions(+), 29 deletions(-) diff --git a/pkg/tui/account.go b/pkg/tui/account.go index 9ec9fb1..23e203d 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -22,9 +22,7 @@ func (m Model) accountBodyHeight() int { footerHeight := lipgloss.Height(m.BuildFooter()) budget := m.heightContainer - headerHeight - footerHeight - 2 cards := budget / orderCardHeight - if cards < 1 { - cards = 1 - } + cards = max(cards, 1) return cards*orderCardHeight - 2 } @@ -468,12 +466,12 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { case "spend limit": if !m.account.spendLimitFocused { ti := textinput.New() - ti.Prompt = "" - ti.Placeholder = "cents (blank = no limit)" - ti.CharLimit = 9 + ti.Prompt = "$" + ti.Placeholder = "dollars (blank = no limit)" + ti.CharLimit = 12 // Prefill with the current limit so the user edits rather than retypes. if m.User != nil && m.User.SelfLimitCents != nil { - ti.SetValue(strconv.Itoa(*m.User.SelfLimitCents)) + ti.SetValue(formatCentsDollars(*m.User.SelfLimitCents)) } cmd := ti.Focus() m.account.spendLimitInput = ti @@ -559,8 +557,7 @@ func (m Model) SpendLimitView(width int) string { current := "no limit set" if m.User != nil && m.User.SelfLimitCents != nil { - c := *m.User.SelfLimitCents - current = fmt.Sprintf("$%.2f (%d cents)", float64(c)/100, c) + current = "$" + formatCentsDollars(*m.User.SelfLimitCents) } out := bodyStyle.Render(wordWrap( @@ -578,7 +575,7 @@ func (m Model) SpendLimitView(width int) string { Border(lipgloss.RoundedBorder()). BorderForeground(m.theme.Highlight()). Width(max(1, width-2)) - out += labelStyle.Render("New limit (cents)") + "\n" + out += labelStyle.Render("New limit (dollars)") + "\n" out += box.Render(m.account.spendLimitInput.View()) switch { case m.account.spendLimitSaving: @@ -616,23 +613,58 @@ func (m Model) setSpendLimitCmd(cents *int) tea.Cmd { } } -// parseSpendLimitInput maps the raw input to a *int of cents. Blank clears the -// limit (nil -> revert to admin/global). "0" is a real "block everything" -// ceiling, NOT a clear. Negative or non-numeric input is rejected. These rules -// match the server boundary in api/handlers/account.go:SetSpendLimit. +// parseSpendLimitInput maps the raw dollar input to a *int of cents. Blank +// clears the limit (nil -> revert to admin/global). "0" is a real "block +// everything" ceiling, NOT a clear. Negative, non-numeric, or more-than-2- +// decimal input is rejected. Parsed as integer cents (dollars*100 + cents) with +// no float multiply, so an amount like 50.01 never loses a cent to binary +// rounding. The server still stores cents (api/handlers/account.go). func parseSpendLimitInput(s string) (*int, error) { s = strings.TrimSpace(s) + s = strings.TrimSpace(strings.TrimPrefix(s, "$")) if s == "" { return nil, nil } - n, err := strconv.Atoi(s) - if err != nil { - return nil, fmt.Errorf("enter a whole number of cents, or blank to clear") - } - if n < 0 { + if strings.HasPrefix(s, "-") { return nil, fmt.Errorf("limit cannot be negative") } - return &n, nil + + whole, frac, hasDot := strings.Cut(s, ".") + if whole == "" { + whole = "0" // allow ".50" + } + dollars, err := strconv.Atoi(whole) + if err != nil { + return nil, fmt.Errorf("enter a dollar amount, e.g 25 or 25.50, or blank to clear") + } + + cents := 0 + if hasDot { + switch len(frac) { + case 0: + // trailing dot, e.g "25." -> treat as .00 + case 1: + frac += "0" // "5" -> 50 cents + fallthrough + case 2: + cents, err = strconv.Atoi(frac) + if err != nil { + return nil, fmt.Errorf("enter a dollar amount, e.g 25 or 25.00, or blank to clear") + } + default: + return nil, fmt.Errorf("use at most two decimal places, e.g 25.00") + } + } + + total := dollars*100 + cents + return &total, nil +} + +// formatCentsDollars renders an integer cents value as a plain dollar string +// (no leading "$"), e.g. 2500 -> "25.00", 50 -> "0.50". Used for the input +// prefill and the current-limit display. +func formatCentsDollars(cents int) string { + return fmt.Sprintf("%d.%02d", cents/100, cents%100) } // defaultAccountFooter is the account-menu footer shown when no sub-view owns diff --git a/pkg/tui/account_test.go b/pkg/tui/account_test.go index 93f2a2f..d2d8d34 100644 --- a/pkg/tui/account_test.go +++ b/pkg/tui/account_test.go @@ -15,13 +15,17 @@ func TestParseSpendLimitInput(t *testing.T) { wantNil bool wantErr bool }{ - {"", 0, true, false}, // blank clears - {"0", 0, false, false}, // 0 is block-all, NOT a clear - {"1500", 1500, false, false}, // plain value - {" 250 ", 250, false, false}, // trims whitespace - {"-5", 0, false, true}, // negative rejected - {"abc", 0, false, true}, // non-numeric rejected - {"12.5", 0, false, true}, // non-integer rejected + {"", 0, true, false}, // blank clears + {"0", 0, false, false}, // $0 is block-all, NOT a clear + {"25", 2500, false, false}, // whole dollars + {"25.50", 2550, false, false}, // dollars + cents + {"25.5", 2550, false, false}, // one decimal => tenths + {"0.05", 5, false, false}, // sub-dollar + {" $25.00 ", 2500, false, false}, // strips $ and whitespace + {".50", 50, false, false}, // leading dot + {"-5", 0, false, true}, // negative rejected + {"abc", 0, false, true}, // non-numeric rejected + {"25.999", 0, false, true}, // too many decimals rejected } for _, tc := range cases { got, err := parseSpendLimitInput(tc.in) @@ -80,8 +84,8 @@ func TestSpendLimitFocusFlow(t *testing.T) { if !m.account.spendLimitFocused { t.Fatal("enter did not focus the spend-limit input") } - if got := m.account.spendLimitInput.Value(); got != "2500" { - t.Errorf("prefill: want \"2500\", got %q", got) + if got := m.account.spendLimitInput.Value(); got != "25.00" { + t.Errorf("prefill: want \"25.00\", got %q", got) } m, _ = m.AccountUpdate(tea.KeyPressMsg{Code: tea.KeyEsc})