From c7b9bc48bca11cc7ac6bfc1dde195cdf59880fe3 Mon Sep 17 00:00:00 2001 From: soky srm Date: Mon, 23 Mar 2026 18:27:53 +0100 Subject: [PATCH] Adapt holdings to number inputs (#1258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adapt holdings to number inputs * Reviews * FIX a small provider hardcoded name * PR l10n request --------- Co-authored-by: Juan José Mata --- app/javascript/utils/parse_locale_float.js | 15 +++- app/views/holdings/_cost_basis_cell.html.erb | 8 +- app/views/holdings/show.html.erb | 8 +- app/views/trades/_header.html.erb | 2 +- app/views/transactions/_header.html.erb | 2 +- config/locales/views/transactions/ca.yml | 1 + config/locales/views/transactions/de.yml | 2 +- config/locales/views/transactions/en.yml | 2 +- config/locales/views/transactions/es.yml | 2 +- config/locales/views/transactions/fr.yml | 2 + config/locales/views/transactions/nb.yml | 2 + config/locales/views/transactions/nl.yml | 1 + config/locales/views/transactions/pt-BR.yml | 2 + config/locales/views/transactions/ro.yml | 2 + config/locales/views/transactions/tr.yml | 2 + config/locales/views/transactions/zh-CN.yml | 2 + config/locales/views/transactions/zh-TW.yml | 2 + test/javascript/parse_locale_float_test.mjs | 90 +++++++++++++++++++- 18 files changed, 131 insertions(+), 16 deletions(-) diff --git a/app/javascript/utils/parse_locale_float.js b/app/javascript/utils/parse_locale_float.js index 4df30a5d9..0011f5eab 100644 --- a/app/javascript/utils/parse_locale_float.js +++ b/app/javascript/utils/parse_locale_float.js @@ -1,9 +1,22 @@ // Parses a float from a string that may use either commas or dots as decimal separators. // Handles formats like "1,234.56" (English) and "1.234,56" (French/European). -export default function parseLocaleFloat(value) { +// +// When a `separator` hint is provided (e.g., from currency metadata), parsing is +// deterministic. Without a hint, a heuristic detects the format from the string. +export default function parseLocaleFloat(value, { separator } = {}) { if (typeof value !== "string") return Number.parseFloat(value) || 0 const cleaned = value.replace(/\s/g, "") + + // Deterministic parsing when the currency's decimal separator is known + if (separator === ",") { + return Number.parseFloat(cleaned.replace(/\./g, "").replace(",", ".")) || 0 + } + if (separator === ".") { + return Number.parseFloat(cleaned.replace(/,/g, "")) || 0 + } + + // Heuristic: detect separator from the string when no hint is available const lastComma = cleaned.lastIndexOf(",") const lastDot = cleaned.lastIndexOf(".") diff --git a/app/views/holdings/_cost_basis_cell.html.erb b/app/views/holdings/_cost_basis_cell.html.erb index 6e4b630bb..f482d0097 100644 --- a/app/views/holdings/_cost_basis_cell.html.erb +++ b/app/views/holdings/_cost_basis_cell.html.erb @@ -61,12 +61,12 @@
<%= currency.symbol %> - " data-action="input->cost-basis-form#updatePerShare" data-cost-basis-form-target="total"> <%= currency.iso_code %> @@ -82,11 +82,11 @@
<%= currency.symbol %> - " data-action="input->cost-basis-form#updateTotal" data-cost-basis-form-target="perShare"> <%= currency.iso_code %> diff --git a/app/views/holdings/show.html.erb b/app/views/holdings/show.html.erb index c94582471..38ff086c0 100644 --- a/app/views/holdings/show.html.erb +++ b/app/views/holdings/show.html.erb @@ -133,12 +133,12 @@
<%= currency.symbol %> - " data-action="input->drawer-cost-basis#updatePerShare" data-drawer-cost-basis-target="total"> <%= currency.iso_code %> @@ -153,11 +153,11 @@
<%= currency.symbol %> - " data-action="input->drawer-cost-basis#updateTotal" data-drawer-cost-basis-target="perShare"> <%= currency.iso_code %> diff --git a/app/views/trades/_header.html.erb b/app/views/trades/_header.html.erb index 3cd66872e..a9f1e12bb 100644 --- a/app/views/trades/_header.html.erb +++ b/app/views/trades/_header.html.erb @@ -18,7 +18,7 @@ <% if entry.linked? %> - + "> <%= icon("refresh-ccw", size: "sm") %> <% end %> diff --git a/app/views/transactions/_header.html.erb b/app/views/transactions/_header.html.erb index f91f5d0ee..d80c13bed 100644 --- a/app/views/transactions/_header.html.erb +++ b/app/views/transactions/_header.html.erb @@ -12,7 +12,7 @@ <%= icon "arrow-left-right", size: "sm", class: "text-secondary" %> <% end %> <% if entry.linked? %> - " class="text-secondary"> + " class="text-secondary"> <%= icon("refresh-ccw", size: "sm") %> <% end %> diff --git a/config/locales/views/transactions/ca.yml b/config/locales/views/transactions/ca.yml index 7a1a2ad04..dc3b94981 100644 --- a/config/locales/views/transactions/ca.yml +++ b/config/locales/views/transactions/ca.yml @@ -122,6 +122,7 @@ ca: transaction: pending: Pendent pending_tooltip: Transacció pendent — pot canviar quan es publiqui + linked_with_provider: Vinculat amb %{provider} possible_duplicate: Duplicat? potential_duplicate_tooltip: Això pot ser un duplicat d'una altra transacció review_recommended: Revisa diff --git a/config/locales/views/transactions/de.yml b/config/locales/views/transactions/de.yml index a8e280497..2b19d6942 100644 --- a/config/locales/views/transactions/de.yml +++ b/config/locales/views/transactions/de.yml @@ -75,7 +75,7 @@ de: transaction: pending: Ausstehend pending_tooltip: Ausstehende Transaktion — kann sich bei Buchung ändern - linked_with_plaid: Mit Plaid verknüpft + linked_with_provider: Mit %{provider} verknüpft activity_type_tooltip: Art der Anlageaktivität possible_duplicate: Duplikat? potential_duplicate_tooltip: Dies könnte ein Duplikat einer anderen Transaktion sein diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index 836aabff3..0c4b5b990 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -86,7 +86,7 @@ en: transaction: pending: Pending pending_tooltip: Pending transaction — may change when posted - linked_with_plaid: Linked with Plaid + linked_with_provider: Linked with %{provider} activity_type_tooltip: Investment activity type possible_duplicate: Duplicate? potential_duplicate_tooltip: This may be a duplicate of another transaction diff --git a/config/locales/views/transactions/es.yml b/config/locales/views/transactions/es.yml index 7e0ea10dc..b5d294422 100644 --- a/config/locales/views/transactions/es.yml +++ b/config/locales/views/transactions/es.yml @@ -76,7 +76,7 @@ es: transaction: pending: Pendiente pending_tooltip: Transacción pendiente — puede cambiar al confirmarse - linked_with_plaid: Vinculado con Plaid + linked_with_provider: Vinculado con %{provider} activity_type_tooltip: Tipo de actividad de inversión possible_duplicate: ¿Duplicada? potential_duplicate_tooltip: Esto puede ser un duplicado de otra transacción diff --git a/config/locales/views/transactions/fr.yml b/config/locales/views/transactions/fr.yml index e4f6960e9..d6aeb52a3 100644 --- a/config/locales/views/transactions/fr.yml +++ b/config/locales/views/transactions/fr.yml @@ -45,6 +45,8 @@ fr: edit_merchants: Modifier les marchands edit_tags: Modifier les étiquettes import: Importer + transaction: + linked_with_provider: Lié avec %{provider} index: transaction: transaction transactions: transactions diff --git a/config/locales/views/transactions/nb.yml b/config/locales/views/transactions/nb.yml index 5661ad062..479659c30 100644 --- a/config/locales/views/transactions/nb.yml +++ b/config/locales/views/transactions/nb.yml @@ -46,6 +46,8 @@ nb: edit_merchants: Rediger selgere edit_tags: Rediger tagger import: Importer + transaction: + linked_with_provider: Koblet med %{provider} index: transaction: transaksjon transactions: transaksjoner diff --git a/config/locales/views/transactions/nl.yml b/config/locales/views/transactions/nl.yml index 4e3bb1dfc..25415e4fc 100644 --- a/config/locales/views/transactions/nl.yml +++ b/config/locales/views/transactions/nl.yml @@ -74,6 +74,7 @@ nl: transaction: pending: Wachtend pending_tooltip: Wachtende transactie — kan wijzigen bij posting + linked_with_provider: Gekoppeld met %{provider} activity_type_tooltip: Beleggingsactiviteitstype possible_duplicate: Duplicaat? potential_duplicate_tooltip: Dit kan een duplicaat zijn van een andere transactie diff --git a/config/locales/views/transactions/pt-BR.yml b/config/locales/views/transactions/pt-BR.yml index 15476c952..5d898a27a 100644 --- a/config/locales/views/transactions/pt-BR.yml +++ b/config/locales/views/transactions/pt-BR.yml @@ -49,6 +49,8 @@ pt-BR: edit_merchants: Editar comerciantes edit_tags: Editar tags import: Importar + transaction: + linked_with_provider: Vinculado com %{provider} index: transaction: transação transactions: transações diff --git a/config/locales/views/transactions/ro.yml b/config/locales/views/transactions/ro.yml index 8312d3406..dd0d71087 100644 --- a/config/locales/views/transactions/ro.yml +++ b/config/locales/views/transactions/ro.yml @@ -45,6 +45,8 @@ ro: edit_merchants: Editează comercianți edit_tags: Editează etichete import: Importă + transaction: + linked_with_provider: Conectat cu %{provider} index: transaction: tranzacție transactions: tranzacții diff --git a/config/locales/views/transactions/tr.yml b/config/locales/views/transactions/tr.yml index b7f545a8e..bb27ce92c 100644 --- a/config/locales/views/transactions/tr.yml +++ b/config/locales/views/transactions/tr.yml @@ -45,6 +45,8 @@ tr: edit_merchants: Satıcıları düzenle edit_tags: Etiketleri düzenle import: İçe aktar + transaction: + linked_with_provider: "%{provider} ile bağlantılı" index: transaction: işlem transactions: işlemler diff --git a/config/locales/views/transactions/zh-CN.yml b/config/locales/views/transactions/zh-CN.yml index b8533bba2..11fa2e3e5 100644 --- a/config/locales/views/transactions/zh-CN.yml +++ b/config/locales/views/transactions/zh-CN.yml @@ -25,6 +25,8 @@ zh-CN: edit_merchants: 编辑商户 edit_tags: 编辑标签 import: 导入 + transaction: + linked_with_provider: 已与 %{provider} 关联 index: import: 导入 transaction: 交易 diff --git a/config/locales/views/transactions/zh-TW.yml b/config/locales/views/transactions/zh-TW.yml index d5bb0fb46..53c080a8e 100644 --- a/config/locales/views/transactions/zh-TW.yml +++ b/config/locales/views/transactions/zh-TW.yml @@ -48,6 +48,8 @@ zh-TW: edit_merchants: 編輯商家 edit_tags: 編輯標籤 import: 匯入 + transaction: + linked_with_provider: 已與 %{provider} 連結 index: transaction: 筆交易 transactions: 筆交易 diff --git a/test/javascript/parse_locale_float_test.mjs b/test/javascript/parse_locale_float_test.mjs index cc233e25e..5d88d9e55 100644 --- a/test/javascript/parse_locale_float_test.mjs +++ b/test/javascript/parse_locale_float_test.mjs @@ -1,11 +1,20 @@ import { describe, it } from "node:test" import assert from "node:assert/strict" -// Inline the function to avoid needing a bundler for ESM imports -function parseLocaleFloat(value) { +// Inline the function to avoid needing a bundler for ESM imports. +// Must be kept in sync with app/javascript/utils/parse_locale_float.js +function parseLocaleFloat(value, { separator } = {}) { if (typeof value !== "string") return Number.parseFloat(value) || 0 const cleaned = value.replace(/\s/g, "") + + if (separator === ",") { + return Number.parseFloat(cleaned.replace(/\./g, "").replace(",", ".")) || 0 + } + if (separator === ".") { + return Number.parseFloat(cleaned.replace(/,/g, "")) || 0 + } + const lastComma = cleaned.lastIndexOf(",") const lastDot = cleaned.lastIndexOf(".") @@ -74,6 +83,10 @@ describe("parseLocaleFloat", () => { it("treats 1,000 as one thousand", () => { assert.equal(parseLocaleFloat("1,000"), 1000) }) + + it("treats 1,000,000 as one million", () => { + assert.equal(parseLocaleFloat("1,000,000"), 1000000) + }) }) describe("integers", () => { @@ -96,6 +109,79 @@ describe("parseLocaleFloat", () => { }) }) + describe("negative numbers", () => { + it("parses negative dot-decimal", () => { + assert.equal(parseLocaleFloat("-1,234.56"), -1234.56) + }) + + it("parses negative comma-decimal", () => { + assert.equal(parseLocaleFloat("-1.234,56"), -1234.56) + }) + + it("parses simple negative", () => { + assert.equal(parseLocaleFloat("-256.54"), -256.54) + }) + + it("parses negative European simple", () => { + assert.equal(parseLocaleFloat("-256,54"), -256.54) + }) + }) + + describe("with separator hint", () => { + describe("comma separator (European currencies like EUR)", () => { + const opts = { separator: "," } + + it("disambiguates 1,234 as 1.234 (European decimal)", () => { + assert.equal(parseLocaleFloat("1,234", opts), 1.234) + }) + + it("parses 1.234,56 correctly", () => { + assert.equal(parseLocaleFloat("1.234,56", opts), 1234.56) + }) + + it("parses simple comma decimal", () => { + assert.equal(parseLocaleFloat("256,54", opts), 256.54) + }) + + it("parses integer without separators", () => { + assert.equal(parseLocaleFloat("1234", opts), 1234) + }) + + it("parses negative value", () => { + assert.equal(parseLocaleFloat("-1.234,56", opts), -1234.56) + }) + }) + + describe("dot separator (English currencies like USD)", () => { + const opts = { separator: "." } + + it("disambiguates 1,234 as 1234 (English thousands)", () => { + assert.equal(parseLocaleFloat("1,234", opts), 1234) + }) + + it("parses 1,234.56 correctly", () => { + assert.equal(parseLocaleFloat("1,234.56", opts), 1234.56) + }) + + it("parses simple dot decimal", () => { + assert.equal(parseLocaleFloat("256.54", opts), 256.54) + }) + + it("parses integer without separators", () => { + assert.equal(parseLocaleFloat("1234", opts), 1234) + }) + + it("parses negative value", () => { + assert.equal(parseLocaleFloat("-1,234.56", opts), -1234.56) + }) + }) + + it("falls back to heuristic when no hint given", () => { + assert.equal(parseLocaleFloat("1,234"), 1234) + assert.equal(parseLocaleFloat("256,54"), 256.54) + }) + }) + describe("edge cases", () => { it("returns 0 for empty string", () => { assert.equal(parseLocaleFloat(""), 0)