From cd30ab502c1181f789ea80ab60a94945759585f9 Mon Sep 17 00:00:00 2001 From: Endika Iglesias Date: Thu, 28 May 2026 22:13:17 +0200 Subject: [PATCH] fix(i18n): pluralize units (1 lata vs 3 latas) --- .../features/event/ConsumptionSummary.tsx | 2 +- .../components/features/event/ExpenseForm.tsx | 2 +- .../components/features/event/PurchasesTab.tsx | 4 ++-- src/presentation/i18n/locales/ca/translation.json | 14 ++++++++++++++ src/presentation/i18n/locales/en/translation.json | 14 ++++++++++++++ src/presentation/i18n/locales/es/translation.json | 14 ++++++++++++++ src/presentation/i18n/locales/eu/translation.json | 14 ++++++++++++++ src/presentation/i18n/locales/gl/translation.json | 14 ++++++++++++++ src/presentation/i18n/locales/va/translation.json | 14 ++++++++++++++ src/presentation/utils/formatShoppingListText.ts | 4 ++-- src/presentation/utils/units.ts | 15 ++++++++++++--- .../utils/formatShoppingListText.test.ts | 4 ++-- 12 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/presentation/components/features/event/ConsumptionSummary.tsx b/src/presentation/components/features/event/ConsumptionSummary.tsx index f5ab461..6ce4b45 100644 --- a/src/presentation/components/features/event/ConsumptionSummary.tsx +++ b/src/presentation/components/features/event/ConsumptionSummary.tsx @@ -43,7 +43,7 @@ export function ConsumptionSummary({ userId }: { userId: string }) { diff --git a/src/presentation/components/features/event/ExpenseForm.tsx b/src/presentation/components/features/event/ExpenseForm.tsx index ef54391..33c6f92 100644 --- a/src/presentation/components/features/event/ExpenseForm.tsx +++ b/src/presentation/components/features/event/ExpenseForm.tsx @@ -275,7 +275,7 @@ export function ExpenseForm({ {listItems.map((p) => { const checked = p.id in links const remaining = Math.max(1, p.totalQuantity - boughtByOthers(p.id)) - const unit = displayUnit(p.unit, t) + const unit = displayUnit(p.unit, t, p.totalQuantity) const assignee = p.assignedTo ? (event.users.find((u) => u.id === p.assignedTo) ?? null) : null diff --git a/src/presentation/components/features/event/PurchasesTab.tsx b/src/presentation/components/features/event/PurchasesTab.tsx index f883c85..895aba7 100644 --- a/src/presentation/components/features/event/PurchasesTab.tsx +++ b/src/presentation/components/features/event/PurchasesTab.tsx @@ -342,7 +342,7 @@ export function PurchasesTab() {
{t(p.kind === 'bring' ? 'purchases.totalToBring' : 'purchases.totalQuantity', { n: Math.round(p.totalQuantity * 100) / 100, - unit: displayUnit(p.unit, t), + unit: displayUnit(p.unit, t, p.totalQuantity), })}
@@ -384,7 +384,7 @@ export function PurchasesTab() { {t('purchases.boughtProgress', { n: round2(bought), total: round2(total), - unit: displayUnit(p.unit, t), + unit: displayUnit(p.unit, t, total), })}
diff --git a/src/presentation/i18n/locales/ca/translation.json b/src/presentation/i18n/locales/ca/translation.json index 15b3bf9..fb3a4ae 100644 --- a/src/presentation/i18n/locales/ca/translation.json +++ b/src/presentation/i18n/locales/ca/translation.json @@ -224,13 +224,27 @@ "update": "Desa els canvis", "units": { "units": "unitats", + "units_one": "unitat", + "units_other": "unitats", "bottles": "ampolles", + "bottles_one": "ampolla", + "bottles_other": "ampolles", "cans": "llaunes", + "cans_one": "llauna", + "cans_other": "llaunes", "kg": "kg", "liters": "litres", + "liters_one": "litre", + "liters_other": "litres", "grams": "grams", + "grams_one": "gram", + "grams_other": "grams", "bag": "bosses", + "bag_one": "bossa", + "bag_other": "bosses", "tray": "safates", + "tray_one": "safata", + "tray_other": "safates", "single": "Unitat única" }, "unitPlaceholder": "ex. ampolles, garrafa 8L", diff --git a/src/presentation/i18n/locales/en/translation.json b/src/presentation/i18n/locales/en/translation.json index bce56bb..28fd2b8 100644 --- a/src/presentation/i18n/locales/en/translation.json +++ b/src/presentation/i18n/locales/en/translation.json @@ -224,13 +224,27 @@ "update": "Save changes", "units": { "units": "units", + "units_one": "unit", + "units_other": "units", "bottles": "bottles", + "bottles_one": "bottle", + "bottles_other": "bottles", "cans": "cans", + "cans_one": "can", + "cans_other": "cans", "kg": "kg", "liters": "liters", + "liters_one": "liter", + "liters_other": "liters", "grams": "grams", + "grams_one": "gram", + "grams_other": "grams", "bag": "bags", + "bag_one": "bag", + "bag_other": "bags", "tray": "trays", + "tray_one": "tray", + "tray_other": "trays", "single": "Single unit" }, "unitPlaceholder": "e.g. bottles, garrafa 8L", diff --git a/src/presentation/i18n/locales/es/translation.json b/src/presentation/i18n/locales/es/translation.json index 0f51848..ff2118f 100644 --- a/src/presentation/i18n/locales/es/translation.json +++ b/src/presentation/i18n/locales/es/translation.json @@ -224,13 +224,27 @@ "update": "Guardar cambios", "units": { "units": "unidades", + "units_one": "unidad", + "units_other": "unidades", "bottles": "botellas", + "bottles_one": "botella", + "bottles_other": "botellas", "cans": "latas", + "cans_one": "lata", + "cans_other": "latas", "kg": "kg", "liters": "litros", + "liters_one": "litro", + "liters_other": "litros", "grams": "gramos", + "grams_one": "gramo", + "grams_other": "gramos", "bag": "bolsas", + "bag_one": "bolsa", + "bag_other": "bolsas", "tray": "bandejas", + "tray_one": "bandeja", + "tray_other": "bandejas", "single": "Unidad única" }, "unitPlaceholder": "ej. botellas, garrafa 8L", diff --git a/src/presentation/i18n/locales/eu/translation.json b/src/presentation/i18n/locales/eu/translation.json index 1b6818c..a03f0dc 100644 --- a/src/presentation/i18n/locales/eu/translation.json +++ b/src/presentation/i18n/locales/eu/translation.json @@ -224,13 +224,27 @@ "update": "Aldaketak gorde", "units": { "units": "unitateak", + "units_one": "unitate", + "units_other": "unitateak", "bottles": "botilak", + "bottles_one": "botila", + "bottles_other": "botilak", "cans": "latak", + "cans_one": "lata", + "cans_other": "latak", "kg": "kg", "liters": "litroak", + "liters_one": "litro", + "liters_other": "litroak", "grams": "gramoak", + "grams_one": "gramo", + "grams_other": "gramoak", "bag": "poltsak", + "bag_one": "poltsa", + "bag_other": "poltsak", "tray": "erretiluak", + "tray_one": "erretilu", + "tray_other": "erretiluak", "single": "Unitate bakarra" }, "unitPlaceholder": "adib. botilak, garrafa 8L", diff --git a/src/presentation/i18n/locales/gl/translation.json b/src/presentation/i18n/locales/gl/translation.json index 5bdf8e8..b4e9e37 100644 --- a/src/presentation/i18n/locales/gl/translation.json +++ b/src/presentation/i18n/locales/gl/translation.json @@ -224,13 +224,27 @@ "update": "Gardar cambios", "units": { "units": "unidades", + "units_one": "unidade", + "units_other": "unidades", "bottles": "botellas", + "bottles_one": "botella", + "bottles_other": "botellas", "cans": "latas", + "cans_one": "lata", + "cans_other": "latas", "kg": "kg", "liters": "litros", + "liters_one": "litro", + "liters_other": "litros", "grams": "gramos", + "grams_one": "gramo", + "grams_other": "gramos", "bag": "bolsas", + "bag_one": "bolsa", + "bag_other": "bolsas", "tray": "bandexas", + "tray_one": "bandexa", + "tray_other": "bandexas", "single": "Unidade única" }, "unitPlaceholder": "ex. botellas, garrafa 8L", diff --git a/src/presentation/i18n/locales/va/translation.json b/src/presentation/i18n/locales/va/translation.json index fdc768c..dbe8e61 100644 --- a/src/presentation/i18n/locales/va/translation.json +++ b/src/presentation/i18n/locales/va/translation.json @@ -224,13 +224,27 @@ "update": "Guarda els canvis", "units": { "units": "unitats", + "units_one": "unitat", + "units_other": "unitats", "bottles": "botelles", + "bottles_one": "botella", + "bottles_other": "botelles", "cans": "llandes", + "cans_one": "llanda", + "cans_other": "llandes", "kg": "kg", "liters": "litres", + "liters_one": "litre", + "liters_other": "litres", "grams": "grams", + "grams_one": "gram", + "grams_other": "grams", "bag": "bosses", + "bag_one": "bossa", + "bag_other": "bosses", "tray": "safates", + "tray_one": "safata", + "tray_other": "safates", "single": "Unitat única" }, "unitPlaceholder": "ex. botelles, garrafa 8L", diff --git a/src/presentation/utils/formatShoppingListText.ts b/src/presentation/utils/formatShoppingListText.ts index 56888fd..f7bdc75 100644 --- a/src/presentation/utils/formatShoppingListText.ts +++ b/src/presentation/utils/formatShoppingListText.ts @@ -1,7 +1,7 @@ import type { EventSnapshot } from '@/domain/entities/Event' import type { PurchaseSnapshot } from '@/domain/entities/Purchase' import { displayUnit } from '@/presentation/utils/units' -type T = (key: string, vars?: Record) => string +type T = (key: string, vars?: Record) => string function formatQty(n: number): string { return Number.isInteger(n) ? String(n) : String(Math.round(n * 10) / 10) @@ -33,7 +33,7 @@ function renderBuyLine(event: EventSnapshot, p: PurchaseSnapshot, t: T): string const checkmark = p.purchased ? ' ✅' : '' const bought = boughtQtyFor(event, p.id) const total = p.totalQuantity - const unit = displayUnit(p.unit, t) + const unit = displayUnit(p.unit, t, total) return ` • ${name}${assigneePart} · ${formatQty(bought)}/${formatQty(total)} ${unit}${checkmark}` } diff --git a/src/presentation/utils/units.ts b/src/presentation/utils/units.ts index c3e9d45..f7e8712 100644 --- a/src/presentation/utils/units.ts +++ b/src/presentation/utils/units.ts @@ -5,7 +5,16 @@ export const SELECTABLE_UNITS = [...VALID_UNITS, SHARED_UNIT] as const const KNOWN = new Set(SELECTABLE_UNITS) -/** Translate a known unit key; fall back to the raw (free-text) unit otherwise. */ -export function displayUnit(unit: string, t: (key: string) => string): string { - return KNOWN.has(unit) ? t(`purchases.form.units.${unit}`) : unit +type T = (key: string, opts?: Record) => string + +/** + * Translate a known unit key, picking singular/plural based on `count` via i18next's + * built-in plural support (_one / _other suffixes). Falls back to the raw (free-text) + * unit otherwise. When `count` is omitted, returns the base plural form (used for + * dropdown labels). + */ +export function displayUnit(unit: string, t: T, count?: number): string { + if (!KNOWN.has(unit)) return unit + const key = `purchases.form.units.${unit}` + return count === undefined ? t(key) : t(key, { count }) } diff --git a/tests/presentation/utils/formatShoppingListText.test.ts b/tests/presentation/utils/formatShoppingListText.test.ts index c72341b..3d3ed07 100644 --- a/tests/presentation/utils/formatShoppingListText.test.ts +++ b/tests/presentation/utils/formatShoppingListText.test.ts @@ -12,11 +12,11 @@ const STUB_TRANSLATIONS: Record = { 'purchases.form.units.single': 'single', } -const t = (key: string, vars?: Record): string => { +const t = (key: string, vars?: Record): string => { let value = STUB_TRANSLATIONS[key] ?? key if (vars) { for (const [k, v] of Object.entries(vars)) { - value = value.replace(`{{${k}}}`, v) + value = value.replace(`{{${k}}}`, String(v)) } } return value