Skip to content

fix(frontend): pin formatCurrency to en-US instead of the host locale - #1732

Merged
cristim merged 3 commits into
mainfrom
fix/locale-independent-currency-format
Aug 8, 2026
Merged

cristim merged 3 commits into
mainfrom
fix/locale-independent-currency-format

Conversation

@cristim

@cristim cristim commented Aug 7, 2026 •

Copy link
Copy Markdown
Member

Summary

  • formatCurrency() (frontend/src/utils.ts) called value.toLocaleString(undefined, {...}), so its digit grouping followed whichever locale the runtime happened to default to. On a locale using . as the thousands separator, $1,000 rendered as $1.000.
  • All 8 failing tests (utils.test.ts x2, riexchange.test.ts x4, approval-details.test.ts x2) trace to this single call site: riexchange.ts and approval-details.ts both format money exclusively through the shared formatCurrency() helper, no separate toLocaleString calls of their own.
  • dashboard.ts had six more occurrences of the identical unpinned-locale pattern, hand-building $${x.toLocaleString()} in Chart.js tooltip/axis-tick callbacks instead of calling the formatCurrency it already imports. Routed through the shared helper too, in a separate commit (see below).

Deliberate vs. accidental

Established this before touching production code, since the fix differs materially depending on the answer:

  • formatCurrency already hardcodes a fixed currency-symbol prefix ($, €, ...) rather than using Intl's currency-style formatting that would adapt symbol placement per locale — the formatting is already meant to be a fixed convention, just with the number grouping left accidentally unpinned.
  • The same file already establishes the precedent: formatDate()/formatDateTime() both explicitly pin 'en-US', with formatDate's doc comment reading "The en-US locale + month: 'short' removes ambiguity from pure numeric forms ('3/25/2026' vs '25.3.2026')... readable for non-technical users." That is the identical ambiguity argument for money, already decided once in this codebase, just not applied consistently to formatCurrency.
  • CUDly's money model is USD-only end to end (no currency field anywhere in the purchase/cap types).
  • formatCurrency's own doc comment discusses only fraction-digit precision, with no mention of locale intent — consistent with an oversight rather than a decision.

Conclusion: production code change, pinning to 'en-US', matching the sibling date formatters in the same file. This is a real behavior change for actual users viewing the app on a non-US-locale browser, not only a test fix — worth stating plainly rather than downplaying. Concretely: a user on a de-DE browser currently sees $1.200 for a value the backend/CI treats as $1,200; after this change they see $1,200, matching every other user regardless of their browser's locale.

Commit 1: pin formatCurrency to en-US

// before
return `${currency}${value.toLocaleString(undefined, { ... })}`;
// after
return `${currency}${value.toLocaleString('en-US', { ... })}`;

One line, plus a comment explaining the reasoning above and referencing this issue.

Commit 2: route dashboard.ts's chart callbacks through formatCurrency

Before fixing the six dashboard.ts sites individually, checked whether they should instead call the shared formatCurrency() helper — dashboard.ts already imports it and uses it elsewhere in the same file. All six format a guaranteed-finite number (never null/undefined, always whole dollars, always prefixed with a literal $), which is exactly formatCurrency's default signature. This was pure duplication, not a case with different precision or non-currency semantics, so all six were replaced with formatCurrency(x) calls:

  • dashboard.ts:965,976-979 — Chart.js tooltip callback strings (Current / Committed, Total, Lowest option, Upside)
  • dashboard.ts:997,1150 — Chart.js axis-tick callbacks (two charts)
  • dashboard.ts:1134 — cumulative-savings tooltip callback

This fixes the locale bug and removes the duplication that let the two formatters drift in the first place. These call sites render into a Chart.js <canvas>, not the DOM, so no existing test exercises them and none are being added here — the change is mechanical (verified by tsc, eslint, and the full suite passing under both locales), not by new coverage of previously-untested chart-rendering code.

Verification (both locales, as required)

Pre-fix baseline:

LANG=en_US.UTF-8: PASS (169) FAIL (0)
LANG=de_DE.UTF-8: PASS (161) FAIL (8)   <- the 8 tests from the issue

Post-fix (commit 1, before the dashboard.ts commit):

LANG=en_US.UTF-8: PASS (169) FAIL (0)
LANG=de_DE.UTF-8: PASS (169) FAIL (0)

Full suite (2781 tests), after both commits, both locales:

LANG=en_US.UTF-8: PASS (2781) FAIL (0) skipped (1)
LANG=de_DE.UTF-8: PASS (2781) FAIL (0) skipped (1)

A fix verified under only one locale wasn't accepted as verification here — both were run and both are clean, before and after the second commit.

Test plan

  • npx tsc --noEmit — clean
  • npx eslint src/utils.ts src/dashboard.ts — clean
  • 8 tests fail under LANG=de_DE.UTF-8 pre-fix, pass under both locales post-fix (evidence above)
  • Full suite (2781 tests) passes under both en_US.UTF-8 and de_DE.UTF-8 post-fix, after both commits
  • npm run build — clean production build

Closes #1728

Summary by CodeRabbit

  • Bug Fixes
    • Standardized currency formatting across dashboard charts.
    • Currency values now display consistently in the U.S. format regardless of browser locale.
    • Improved consistency for savings tooltips, axis labels, and invalid-value handling.

@cristim cristim added triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline impact/internal Team-internal only effort/s Hours type/bug Defect labels Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The shared formatCurrency helper now fixes number formatting to the en-US locale. Dashboard chart tooltips and axis labels use this helper for currency values.

Changes

Currency formatting consistency

Layer / File(s) Summary
Shared currency locale
frontend/src/utils.ts
formatCurrency now uses en-US digit grouping and documents the fixed locale behavior.
Dashboard chart formatting
frontend/src/dashboard.ts
Per-service and savings-trend chart tooltips and y-axis labels now use formatCurrency.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related issues

  • LeanerCloud/CUDly issue 1733: It addresses the same dashboard currency-formatting behavior and fixed en-US locale.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address issue #1728 by removing host-locale dependence and routing dashboard currency formatting through the shared en-US formatter.
Out of Scope Changes check ✅ Passed All changes support the linked issue by standardizing currency formatting and removing duplicated host-locale behavior.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: fixing formatCurrency to use the en-US locale.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/locale-independent-currency-format

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/dashboard.ts (1)

965-979: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Split the chart code into bounded frontend modules.

frontend/src/dashboard.ts is at least 1,167 lines. This violates the TypeScript rule that files stay under 500 lines. Move the savings chart renderers and their callbacks into the appropriate dashboard chart bounded context before extending this module further.

As per coding guidelines, TypeScript files must follow bounded contexts and stay under 500 lines.

Also applies to: 997-997, 1134-1150

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/dashboard.ts` around lines 965 - 979, Split the savings chart
renderers and their callbacks out of the oversized dashboard module into an
appropriate bounded dashboard chart module. Move the logic surrounding the
savings breakdown, including the code near the current/committed renderer and
the referenced sections around lines 997 and 1134–1150, while preserving
existing behavior and keeping dashboard.ts under 500 lines.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@frontend/src/dashboard.ts`:
- Around line 965-979: Split the savings chart renderers and their callbacks out
of the oversized dashboard module into an appropriate bounded dashboard chart
module. Move the logic surrounding the savings breakdown, including the code
near the current/committed renderer and the referenced sections around lines 997
and 1134–1150, while preserving existing behavior and keeping dashboard.ts under
500 lines.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bcc2ac27-c741-4bd8-ac84-6789868cc8e8

📥 Commits

Reviewing files that changed from the base of the PR and between 9102e1c and ab2b845.

📒 Files selected for processing (2)
  • frontend/src/dashboard.ts
  • frontend/src/utils.ts

cristim added 2 commits August 8, 2026 01:42
formatCurrency() passed undefined as the locale to toLocaleString,
so its digit grouping followed whichever locale the runtime (browser
or, for tests, the machine running jest) happened to default to. On
a locale that uses '.' as the thousands separator, $1,000 rendered as
$1.000. Eight tests across utils.test.ts, riexchange.test.ts and
approval-details.test.ts asserted the en-US grouping directly, so
they failed locally on such a machine and passed in CI purely by
accident of environment -- a genuine formatting regression under
CI's own locale would have stayed green too.

Pins the locale to 'en-US', matching the convention formatDate() and
formatDateTime() already use in this same file for the identical
ambiguity reason. formatCurrency's currency-symbol prefix is already
locale-invariant (a fixed "$"/"€"/etc, not Intl currency-style
formatting), so the digits after it should be too: CUDly's money
model is USD-only end to end, and two admins should see the same
digits for the same dollar figure regardless of their browser's
locale. This is a production behavior change for real users on a
non-US-locale browser, not only a test fix.

Verified the 8 tests fail under LANG=de_DE.UTF-8 and pass under
LANG=en_US.UTF-8 before this change, and pass under both locales
after. Full suite (2781 tests) also passes under both locales
post-fix.

dashboard.ts has several independent toLocaleString() calls in
Chart.js tooltip/axis-tick callbacks with the same unpinned-locale
pattern; left untouched since no test currently exercises them and
they are out of this issue's scope.
dashboard.ts's Chart.js tooltip and axis-tick callbacks built dollar
strings by hand with `$${x.toLocaleString()}`, six occurrences across
two charts. Same unpinned-locale defect as formatCurrency() before
the previous commit -- these fell back to the host locale too -- but
the deeper issue was duplication: dashboard.ts already imports
formatCurrency from utils.ts and uses it elsewhere in the same file,
so these six were reinventing it inline instead of calling it.

Routes all six through the shared helper. Mechanical: each site
formats a guaranteed-finite number (never null/undefined) with no
fraction digits and a literal '$' prefix, exactly formatCurrency's
default signature, so the change is a straight substitution with no
behavior difference beyond the locale fix itself.

These call sites render into a Chart.js <canvas>, not the DOM, so no
existing test exercises them and none are being added here -- the
change is verified by tsc, eslint, and the full suite passing under
both locales, not by new coverage of previously-untested chart code.
@cristim
cristim force-pushed the fix/locale-independent-currency-format branch from ab2b845 to 5529dd6 Compare August 7, 2026 23:45
@cristim

cristim commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Rebased onto current origin/main (post-#1729 npm advisory fix) to clear the merge-blocked state. Mechanical rebase, no conflicts. Diff vs merge-base is byte-identical pre/post rebase. Re-verified post-rebase under both locales as required (a rebase that quietly reintroduced the unpinned host-locale call would pass under en_US and fail only elsewhere):

LANG=en_US.UTF-8: PASS (2781) FAIL (0) skipped (1)
LANG=de_DE.UTF-8: PASS (2781) FAIL (0) skipped (1)

Zero failures under de_DE confirms the fix (both the formatCurrency pin and the dashboard.ts DRY follow-up) survived the rebase intact. tsc --noEmit, eslint, and npm run build clean. New head: 5529dd6.

@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Full review, head 5529dd65c

Independent reviewer, first review on this PR. Derived by execution in a clean worktree; findings split into execution-verified and reading-derived.

The core change is correct and consistent

en-US is the established convention here, not a new invention. Seven pre-existing sites already pin it (utils.ts:100,112, apikeys.ts:460, recommendations.ts:5028, chart-utils.ts:13,15), formatDate's doc comment states the rationale, and there is no competing pinned locale anywhere in the frontend. There are no Intl.NumberFormat / Intl.DateTimeFormat constructors at all, so toLocaleString is the whole surface.

The currency symbol interaction is coherent: formatCurrency's second parameter defaults to '$' and every production call passes '$' or omits it, so no live call produces an incongruent €1,000. The currency code is not hardcoded as 'USD' anywhere.

The fix demonstrably works. Reproduced independently rather than taking the PR's word:

build locale result
head en_US.UTF-8 91/91 pass
head de_DE.UTF-8 91/91 pass
utils.ts reverted to origin/main en_US.UTF-8 91/91 pass
utils.ts reverted to origin/main de_DE.UTF-8 2 failed, 89 passed
✕ formats positive numbers correctly     Expected: "$1,000"  Received: "$1.000"
✕ supports custom currency symbol        Expected: "€1,000"  Received: "€1.000"

Node here is built with full ICU and LC_ALL genuinely drives Intl's default locale, so these are real host-locale runs, not an explicit-locale substitute.


F1 The pin is partial, and the gap is in the same file and the same view

This is the one I would want fixed. frontend/src/utils.ts:167:

month: d.toLocaleString('default', { month: 'short' })

'default' is not a pin. It is an explicit request for the host locale, six lines below the formatCurrency this PR just pinned. Verified live:

HOST-default  "Mär"      en-US  "Mar"      de-DE  "Mär"      fr-FR  "mars"

And it is not in some unrelated corner. dashboard.ts:8 imports formatCurrency and getDateParts from the same module, and dashboard.ts:443 calls getDateParts to render the month badge on the upcoming-purchases cards. So after this PR the dashboard shows en-US money and en-US dates everywhere except one month abbreviation that still follows the browser. That is exactly the failure mode where a partial pin is worse than none: the remaining inconsistency is now much harder to notice, because everything around it looks pinned.

It is invisible to the suite by construction: utils.test.ts:139 asserts only expect(result.month).toBeTruthy(), and both dashboard.test.ts and dashboard-ownership-950.test.ts mock getDateParts to a fixed { day: 15, month: 'Jan' }.

One-line fix ('default' to 'en-US'), plus an assertion that the month is 'Mar' rather than merely truthy.

F2 The dashboard.ts half of this PR has zero test coverage

dashboard.test.ts:104 does jest.mock('../utils', ...) and replaces formatCurrency with a stub returning `$${n}` — no separators, no locale. The dashboard tests therefore cannot observe the difference between the old toLocaleString() call sites and the new formatCurrency ones, in any locale. Every one of the changed dashboard call sites is reading-verified only.

That does not make the change wrong, and routing them through the shared helper is clearly the right direction. It just means the PR's verification evidence covers utils.ts and not the file where most of the diff lives.

F3 Nothing guards the pin, and the existing tests were victims rather than guards

The two tests that fail under de_DE are pre-existing; this PR adds no tests at all. And note the third row of the table above: under en_US, the pre-PR code passes 91/91. CI runs en-US. So the suite was green in CI with the bug present for its entire life, and it will be green in CI whether or not the pin survives a future refactor.

This is the same shape as the vacuous-assertion problem: tests that pass for a reason unrelated to the property you believe they protect. utils.test.ts:60 already shows the right pattern for the sibling concern (asserting formatDate's output shape regardless of host). One test asserting formatCurrency(1000) is '$1,000' with the host locale forced to de-DE would turn the existing accidental coverage into a real guard, and would be the natural place to also pin F1.

No parse-side exposure. Checked specifically; there is none.

Looked for the format to store/edit to parse cycle that would turn 1,234.56 versus 1.234,56 into a factor-of-1000 money error. It does not exist:

  • No input is ever populated by a formatter. A sweep for .value = ...formatCurrency/toLocaleString/toFixed/formatDate, setAttribute('value', and defaultValue = returns zero hits.
  • The one money round-trip is symmetric and locale-independent: groupModals.ts:172 writes the purchase-cap input with raw String(...) (always . decimals, never grouped) and groupModals.ts:227,235 reads it back with parseFloat.
  • The numeric column filters (lib/column-filters.ts:44-64) parse user-typed expressions against strict regexes and apply them to raw numeric cell values, never to rendered strings.

Minor

  • 'en-US' is a bare literal repeated at 8 sites with no shared constant, even though utils.ts:14 exports CURRENCY_DEFAULT_DIGITS for precisely this reason ("stay in lock-step rather than hard-coding 0 and silently drifting"). F1 is that drift, in the one dimension that never got the constant. A const UI_LOCALE = 'en-US' would make the next 'default' obvious.
  • Two now-false comments that this PR makes stale: recommendations.test.ts:1223 still says "formatCurrency uses toLocaleString(undefined, ...)" and keeps its separator optional (/\$1,?100/) to tolerate a bug that no longer exists; recommendations.test.ts:7328 still strips "any locale group separators".
  • Out of scope, flagging only: modules/savings-history.ts:332 defines a private formatCurrency that shadows the shared one (the module never imports it), rendering $${v.toFixed(2)} at seven call sites. Not a locale bug (toFixed is invariant), but it is the same divergence-from-the-shared-helper this PR is otherwise fixing, on a chart adjacent to the ones it touched.
  • Out of scope, unrelated to locale, but a money guard: groupModals.ts:172 uses max_amount || '', so a stored cap of 0 renders as an empty input and groupModals.ts:234's if (maxAmount) then drops the constraint on save, widening a "$0 cap" to no cap. I did not verify whether 0 is reachable from the backend.

Verdict

The change is right, matches the existing convention, and genuinely fixes a real bug that I reproduced. Nothing here is a correctness risk on the money path, because there is no parse side to desynchronise.

I would not merge it as the complete fix its title implies, though. F1 leaves a host-locale formatter six lines away, feeding the same dashboard view — one line to fix, and fixing it here is much cheaper than discovering it later as "we already pinned the locale, so it can't be that". F2 and F3 are about evidence rather than code, but together they mean nothing in CI would notice if the pin regressed.

Execution-verified: the four-cell locale matrix above (my own runs, utils.ts reverted from origin/main and restored), the getDateParts locale divergence, and the getDateParts to dashboard.ts:443 call path. Reading-derived: the formatter/parser sweeps and the minor items.

getDateParts() called toLocaleString('default', {month: 'short'}) --
an explicit request for the host locale, the exact thing PR #1732
exists to eliminate from formatCurrency/formatDate/formatDateTime.
dashboard.ts imports both formatCurrency and getDateParts and renders
them side by side on the upcoming-purchases cards, so the dashboard
was showing en-US money and en-US dates except one month abbreviation
that still followed the browser (e.g. "Mär" under de-DE while
everything else read "Mar").

Pins it to 'en-US', matching the other helpers in this file.

Also adds regression coverage the original pin lacked: the pre-PR
suite passed 91/91 under en-US (CI's own locale), so nothing actually
exercised the locale-dependent branch. utils.test.ts now simulates a
de-DE host default (by intercepting the "no explicit locale" call
shape used by unpinned toLocaleString calls) and asserts formatCurrency
and getDateParts still render en-US output. Verified both assertions
fail pre-fix and pass post-fix:
  - reverting getDateParts to 'default': expected "Mar", got "Mär"
  - reverting formatCurrency to toLocaleString(undefined, ...):
    expected "$1,000", got "$1.000"

Corrects two now-stale comments in recommendations.test.ts that
described formatCurrency as locale-dependent (toLocaleString(undefined,
...)); it's been pinned to en-US since #1732.

Filed #1747 for a separate, out-of-scope duplication issue found in
review: modules/savings-history.ts has its own private formatCurrency
shadowing this one (different, toFixed-based implementation, not
locale-dependent).
@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Addressed two findings from an independent adversarial review of this PR (both confirmed by execution):

F1 — partial pin. getDateParts() in utils.ts still called d.toLocaleString('default', { month: 'short' }) — an explicit request for the host locale, six lines below the formatCurrency pin this PR added. dashboard.ts renders formatCurrency and getDateParts side by side on the upcoming-purchases cards, so the dashboard showed en-US money and en-US dates except one month abbreviation that still followed the browser. Pinned it to 'en-US', matching the other 7 pins already in this file.

F2 — the pre-PR tests never covered this. The suite passed 91/91 under CI's own en-US locale before this PR, so it was green with the bug present and would stay green whether or not the pin survives a future refactor. Added describe('locale independence (issue LeanerCloud/cloud-commitments-cli#1728)', ...) in utils.test.ts that simulates a de-DE host default (by intercepting the "no explicit locale" call shape used by unpinned toLocaleString calls — the exact shape both bugs used) and asserts formatCurrency and getDateParts still render en-US output. Also tightened the existing getDateParts assertion from toBeTruthy() to toBe('Mar').

Verification (reverted each fix individually, ran the new assertions, captured actual output):

  • Reverted getDateParts back to 'default': Expected: "Mar", Received: "Mär" — test fails as expected.
  • Reverted formatCurrency back to toLocaleString(undefined, ...): Expected: "$1,000", Received: "$1.000" — test fails as expected.
  • Restored both fixes: both new tests pass, full suite green.

Also corrected two now-stale comments in recommendations.test.ts (:1222-1224 and :7328) that described formatCurrency as locale-dependent (toLocaleString(undefined, ...)); it's been pinned to en-US since this PR.

Out of scope, filed separately: modules/savings-history.ts:332 has its own private formatCurrency that shadows the shared one (different toFixed-based implementation, not locale-dependent, so not part of this bug) — filed LeanerCloud/cloud-commitments-platform#171 to rename it and remove the naming collision.

Gate results (frontend, CI-pinned tool versions where available — Node 22 locally vs CI's Node 24, no version manager available in this environment; TS/eslint/jest all installed from the repo's pinned package-lock.json):

  • npx tsc --noEmit — exit 0, no errors
  • npm run lint — exit 0, 0 errors (125 pre-existing no-explicit-any warnings, unrelated to this diff)
  • npm run build — exit 0
  • npx jest --no-coverage --silent — exit 0, 87 suites / 2783 passed, 1 skipped (2784 total, up from 2781 pre-existing + 2 new locale tests, minus 0 net removed)

@cristim cristim closed this Aug 8, 2026
@cristim cristim reopened this Aug 8, 2026
@cristim
cristim merged commit e0fc45e into main Aug 8, 2026
41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/internal Team-internal only priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/bug Defect urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(test/frontend): 8 tests assert the host locale's thousands separator, so they fail locally and pass in CI

1 participant