diff --git a/src/components/balance/AccountForm.tsx b/src/components/balance/AccountForm.tsx index 128e4c4..15a8bc0 100644 --- a/src/components/balance/AccountForm.tsx +++ b/src/components/balance/AccountForm.tsx @@ -16,12 +16,15 @@ import type { BalanceAssetType, BalanceCategory, BalanceCategoryKind, + BalanceVehicleType, } from "../../shared/types"; +import { BALANCE_VEHICLE_TYPES } from "../../shared/types"; import type { CreateBalanceAccountInput, CreateBalanceCategoryInput, UpdateBalanceAccountInput, } from "../../services/balance.service"; +import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel"; // ----------------------------------------------------------------------------- // Account variant types @@ -32,6 +35,8 @@ export interface AccountFormValues { name: string; symbol: string; notes: string; + /** Fiscal envelope; "" means "no envelope" (→ null in the payload). */ + vehicle_type: BalanceVehicleType | ""; } interface AccountVariantProps { @@ -52,7 +57,12 @@ interface AccountVariantProps { export interface CategoryFormValues { key: string; - i18n_key: string; + /** + * User-facing label (Issue #203). Written to `custom_label`, never to + * `i18n_key` — consistent with the rename flow and the bug-I fix. The + * service derives `i18n_key` from the key for user-created categories. + */ + custom_label: string; kind: BalanceCategoryKind; /** Required when kind === 'priced' (Issue #169). NULL otherwise. */ asset_type: BalanceAssetType | null; @@ -77,6 +87,7 @@ function defaultAccountValues( name: initial.name, symbol: initial.symbol ?? "", notes: initial.notes ?? "", + vehicle_type: initial.vehicle_type ?? "", }; } // First active category as a sane default @@ -86,6 +97,7 @@ function defaultAccountValues( name: "", symbol: "", notes: "", + vehicle_type: "", }; } @@ -136,11 +148,16 @@ function AccountVariant({ setTouched(true); if (!trimmedName) return; + // "" in the select means "no envelope" → null on the wire. + const vehicleType: BalanceVehicleType | null = + values.vehicle_type === "" ? null : values.vehicle_type; + const payload: CreateBalanceAccountInput = { balance_category_id: values.balance_category_id, name: trimmedName, symbol: trimmedSymbol || null, notes: values.notes.trim() || null, + vehicle_type: vehicleType, }; if (isEditing) { @@ -149,6 +166,7 @@ function AccountVariant({ name: payload.name, symbol: payload.symbol, notes: payload.notes, + vehicle_type: vehicleType, }; await onSubmit(updatePayload); } else { @@ -156,9 +174,6 @@ function AccountVariant({ } }; - const renderCategoryLabel = (cat: BalanceCategory) => - t(cat.i18n_key, { defaultValue: cat.key }); - return (
@@ -181,7 +196,7 @@ function AccountVariant({ ) : ( categories.map((cat) => ( )) )} @@ -251,6 +266,36 @@ function AccountVariant({ />
+
+ + +

+ {t("balance.account.form.vehicleType.hint")} +

+
+

{t("balance.account.form.currencyMvpNotice")}

@@ -290,14 +335,14 @@ function CategoryVariant({ const { t } = useTranslation(); const [values, setValues] = useState({ key: "", - i18n_key: "", + custom_label: "", kind: "simple", asset_type: null, }); const [touched, setTouched] = useState(false); const trimmedKey = values.key.trim(); - const trimmedLabel = values.i18n_key.trim(); + const trimmedLabel = values.custom_label.trim(); const keyInvalid = touched && trimmedKey.length === 0; const assetTypeMissing = touched && values.kind === "priced" && !values.asset_type; @@ -321,14 +366,16 @@ function CategoryVariant({ setTouched(true); if (!trimmedKey) return; if (values.kind === "priced" && !values.asset_type) return; - // Fall back to the key if no human label was supplied. - const i18nKey = trimmedLabel || trimmedKey; + // User-created categories have no bundled translation: anchor `i18n_key` + // on the key (stable lookup, no free text) and carry the human label in + // `custom_label` — same mechanism as renaming (Issue #203 / bug I). await onSubmit({ key: trimmedKey, - i18n_key: i18nKey, + i18n_key: `balance.category.${trimmedKey}`, kind: values.kind, sort_order: 100, // user-created categories sort after seeded ones asset_type: values.kind === "priced" ? values.asset_type : null, + custom_label: trimmedLabel || null, }); }; @@ -368,21 +415,24 @@ function CategoryVariant({
- setValues({ ...values, i18n_key: e.target.value }) + setValues({ ...values, custom_label: e.target.value }) } - placeholder={t("balance.category.form.labelPlaceholder")} + placeholder={t("balance.category.form.customLabelPlaceholder")} className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" autoComplete="off" /> +

+ {t("balance.category.form.customLabelHint")} +

diff --git a/src/components/balance/BalanceAccountsTable.tsx b/src/components/balance/BalanceAccountsTable.tsx index c003337..70addf1 100644 --- a/src/components/balance/BalanceAccountsTable.tsx +++ b/src/components/balance/BalanceAccountsTable.tsx @@ -27,6 +27,7 @@ import type { } from "../../services/balance.service"; import { computeAccountReturn } from "../../services/balance.service"; import type { AccountReturn } from "../../shared/types"; +import { renderCategoryLabelFromAccount } from "../../utils/renderCategoryLabel"; const cadFormatter = (locale: string) => new Intl.NumberFormat(locale, { @@ -287,7 +288,7 @@ export default function BalanceAccountsTable({ ) : null} - {t(acc.category_i18n_key, { defaultValue: acc.category_key })} + {renderCategoryLabelFromAccount(acc, t)} {acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"} diff --git a/src/components/balance/SnapshotEditor.tsx b/src/components/balance/SnapshotEditor.tsx index 0fb4e05..7c9dbec 100644 --- a/src/components/balance/SnapshotEditor.tsx +++ b/src/components/balance/SnapshotEditor.tsx @@ -13,6 +13,7 @@ import type { } from "../../shared/types"; import type { PricedEntry } from "../../hooks/useSnapshotEditor"; import SnapshotLineRow from "./SnapshotLineRow"; +import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel"; interface Props { accounts: BalanceAccountWithCategory[]; @@ -81,7 +82,7 @@ export default function SnapshotEditor({ >

- {t(category.i18n_key, { defaultValue: category.key })} + {renderCategoryLabelFromCategory(category, t)}

diff --git a/src/hooks/useBalanceAccounts.ts b/src/hooks/useBalanceAccounts.ts index bfafba9..0700b99 100644 --- a/src/hooks/useBalanceAccounts.ts +++ b/src/hooks/useBalanceAccounts.ts @@ -115,7 +115,11 @@ export function useBalanceAccounts() { try { const [accounts, categories] = await Promise.all([ listBalanceAccounts({ includeArchived }), - listBalanceCategories(), + // Exclude v13-deactivated ex-envelope seeds (tfsa/rrsp) from both the + // account-form category dropdown AND the category-management list + // (Issue #203). Existing accounts were re-linked to `other` by v13, so + // nothing points at a deactivated seed — filtering is safe. + listBalanceCategories({ includeInactive: false }), ]); if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_DATA", payload: { accounts, categories } }); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index df49aba..ac32acd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1643,7 +1643,18 @@ "notes": "Notes", "currencyMvpNotice": "At the MVP, all accounts are in CAD. Multi-currency support will land in a later version.", "save": "Save", - "create": "Create account" + "create": "Create account", + "vehicleType": { + "label": "Fiscal envelope", + "none": "None", + "unregistered": "Non-registered", + "tfsa": "TFSA", + "rrsp": "RRSP", + "rrif": "RRIF", + "fhsa": "FHSA", + "resp": "RESP", + "hint": "Tax shelter for this account (TFSA, RRSP…). Optional — the asset class stays the type." + } } }, "category": { @@ -1680,7 +1691,10 @@ "kindHintSimple": "Direct value entry (e.g. checking-account balance).", "kindHintPriced": "Quantity × unit price entry (e.g. stocks, crypto). A symbol is optional for linked accounts (only needed for automatic price fetching).", "simpleOnlyNotice": "Priced types (stocks, crypto) will be available in a future release.", - "create": "Create type" + "create": "Create type", + "customLabel": "Label", + "customLabelPlaceholder": "e.g. My RRIF, Travel savings", + "customLabelHint": "Display name for this type. Leave blank to use the default label." }, "assetType": { "label": "Asset type", @@ -1759,7 +1773,8 @@ "snapshot_priced_quantity_required": "Quantity is required for priced accounts.", "snapshot_priced_unit_price_required": "Unit price is required for priced accounts.", "snapshot_priced_value_mismatch": "The entered value does not match quantity × unit price.", - "snapshot_simple_must_be_scalar": "A simple value must not carry quantity or price." + "snapshot_simple_must_be_scalar": "A simple value must not carry quantity or price.", + "vehicle_type_invalid": "Invalid fiscal envelope." }, "returns": { "partialTooltip": "Partial return: a snapshot is missing for the selected period.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 7a83aa0..4599fa4 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1643,7 +1643,18 @@ "notes": "Notes", "currencyMvpNotice": "Au MVP, tous les comptes sont en CAD. Le support multi-devises arrivera dans une version ultérieure.", "save": "Enregistrer", - "create": "Créer le compte" + "create": "Créer le compte", + "vehicleType": { + "label": "Véhicule fiscal", + "none": "Aucun", + "unregistered": "Non-enregistré", + "tfsa": "CELI", + "rrsp": "REER", + "rrif": "FERR", + "fhsa": "CELIAPP", + "resp": "REEE", + "hint": "Enveloppe fiscale du compte (CELI, REER…). Optionnel — la classe d'actif reste le type." + } } }, "category": { @@ -1680,7 +1691,10 @@ "kindHintSimple": "Saisie d'un montant direct (ex: solde de compte courant).", "kindHintPriced": "Saisie d'une quantité × prix unitaire (ex: actions, cryptomonnaies). Un symbole est optionnel pour les comptes liés (requis seulement pour la récupération automatique des prix).", "simpleOnlyNotice": "Les types cotés (actions, crypto) seront disponibles dans une prochaine version.", - "create": "Créer le type" + "create": "Créer le type", + "customLabel": "Libellé", + "customLabelPlaceholder": "ex. Mon FERR, Épargne voyage", + "customLabelHint": "Nom affiché pour ce type. Laisser vide pour utiliser le libellé par défaut." }, "assetType": { "label": "Type d'actif", @@ -1759,7 +1773,8 @@ "snapshot_priced_quantity_required": "La quantité est obligatoire pour les comptes cotés.", "snapshot_priced_unit_price_required": "Le prix unitaire est obligatoire pour les comptes cotés.", "snapshot_priced_value_mismatch": "La valeur saisie ne correspond pas à quantité × prix unitaire.", - "snapshot_simple_must_be_scalar": "Une valeur simple ne doit pas comporter de quantité ou de prix." + "snapshot_simple_must_be_scalar": "Une valeur simple ne doit pas comporter de quantité ou de prix.", + "vehicle_type_invalid": "Véhicule fiscal invalide." }, "returns": { "partialTooltip": "Rendement partiel : un snapshot manque pour calculer la performance sur cette période.", diff --git a/src/pages/AccountsPage.tsx b/src/pages/AccountsPage.tsx index cc00613..5725463 100644 --- a/src/pages/AccountsPage.tsx +++ b/src/pages/AccountsPage.tsx @@ -21,6 +21,10 @@ import type { import { useBalanceAccounts } from "../hooks/useBalanceAccounts"; import AccountForm from "../components/balance/AccountForm"; import type { CreateBalanceCategoryInput } from "../services/balance.service"; +import { + renderCategoryLabelFromAccount, + renderCategoryLabelFromCategory, +} from "../utils/renderCategoryLabel"; type Tab = "accounts" | "categories"; @@ -66,7 +70,7 @@ export default function AccountsPage() { }, [state.accounts]); const renderCategoryLabel = (cat: BalanceCategory) => - t(cat.i18n_key, { defaultValue: cat.key }); + renderCategoryLabelFromCategory(cat, t); const closeAccountForm = () => { setShowAccountForm(false); @@ -251,9 +255,7 @@ export default function AccountsPage() { - {t(acc.category_i18n_key, { - defaultValue: acc.category_key, - })} + {renderCategoryLabelFromAccount(acc, t)} {acc.symbol ?? "—"} @@ -412,8 +414,14 @@ export default function AccountsPage() { t("balance.category.actions.renamePrompt"), renderCategoryLabel(cat) ); - if (next && next.trim()) { - editCategory(cat.id, { i18n_key: next.trim() }); + // Write the human label to custom_label, never to + // i18n_key — preserves the bundled translation + // (fixes bug I). A blank/empty answer clears the + // override and falls back to t(i18n_key). + if (next !== null) { + editCategory(cat.id, { + custom_label: next.trim() || null, + }); } }} className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]" diff --git a/src/pages/BalancePage.tsx b/src/pages/BalancePage.tsx index b184641..fb109e7 100644 --- a/src/pages/BalancePage.tsx +++ b/src/pages/BalancePage.tsx @@ -33,6 +33,7 @@ import BalanceAccountsTable from "../components/balance/BalanceAccountsTable"; import LinkTransfersModal from "../components/balance/LinkTransfersModal"; import StarterAccountsModal from "../components/balance/StarterAccountsModal"; import { getPreference, setPreference } from "../services/userPreferenceService"; +import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel"; const STARTER_PREF_KEY = "balance_starter_proposed"; @@ -143,9 +144,7 @@ export default function BalancePage() { const m: Record = {}; for (const a of state.accountsLatest) { if (!m[a.category_key]) { - m[a.category_key] = t(a.category_i18n_key, { - defaultValue: a.category_key, - }); + m[a.category_key] = renderCategoryLabelFromAccount(a, t); } } return m; diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index ca78fc2..ecab6fa 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -25,7 +25,7 @@ import type { BalanceTransferDirection, BalanceVehicleType, } from "../shared/types"; -import { BALANCE_CURRENCY_CAD } from "../shared/types"; +import { BALANCE_CURRENCY_CAD, BALANCE_VEHICLE_TYPES } from "../shared/types"; // ----------------------------------------------------------------------------- // Errors — typed so the UI can show distinct i18n messages. @@ -345,15 +345,12 @@ export interface CreateBalanceAccountInput { vehicle_type?: BalanceVehicleType | null; } -/** The six recognized fiscal envelopes. Kept in sync with the SQL CHECK. */ -const VEHICLE_TYPES: readonly BalanceVehicleType[] = [ - "unregistered", - "tfsa", - "rrsp", - "rrif", - "fhsa", - "resp", -]; +/** + * The six recognized fiscal envelopes. Re-uses the shared/types const so the + * service validation and the account-form dropdown (Issue #203) share one + * source of truth. Kept in sync with the SQL CHECK. + */ +const VEHICLE_TYPES = BALANCE_VEHICLE_TYPES; /** * Validate an optional `vehicle_type` against the fiscal-envelope enum. diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index c2c512f..0caf51b 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -588,6 +588,21 @@ export type BalanceVehicleType = | "fhsa" | "resp"; +/** + * The six recognized fiscal envelopes, in display order. Single source of + * truth shared by the account form dropdown (Issue #203) and the service-side + * `normalizeVehicleType` validation. Kept in sync with the SQL CHECK on + * `balance_accounts.vehicle_type`. + */ +export const BALANCE_VEHICLE_TYPES: readonly BalanceVehicleType[] = [ + "unregistered", + "tfsa", + "rrsp", + "rrif", + "fhsa", + "resp", +]; + export const BALANCE_CURRENCY_CAD = "CAD"; export interface BalanceCategory { diff --git a/src/utils/renderCategoryLabel.test.ts b/src/utils/renderCategoryLabel.test.ts new file mode 100644 index 0000000..022dc2e --- /dev/null +++ b/src/utils/renderCategoryLabel.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { + renderCategoryLabel, + renderCategoryLabelFromCategory, + renderCategoryLabelFromAccount, +} from "./renderCategoryLabel"; + +// Fake `t`: echoes the key prefixed, but honours defaultValue for unknown keys +// the way react-i18next does. We model "known" keys explicitly. +const known: Record = { + "balance.category.cash": "Liquidités", +}; +const t = (key: string, options?: { defaultValue?: string }) => + known[key] ?? options?.defaultValue ?? key; + +describe("renderCategoryLabel", () => { + it("prefers a non-empty custom_label over the i18n translation", () => { + expect( + renderCategoryLabel("Mon CELI", "balance.category.cash", "cash", t) + ).toBe("Mon CELI"); + }); + + it("trims custom_label and treats whitespace-only as absent", () => { + expect( + renderCategoryLabel(" ", "balance.category.cash", "cash", t) + ).toBe("Liquidités"); + }); + + it("falls back to the i18n translation when custom_label is null", () => { + expect( + renderCategoryLabel(null, "balance.category.cash", "cash", t) + ).toBe("Liquidités"); + }); + + it("falls back to the i18n translation when custom_label is undefined", () => { + expect( + renderCategoryLabel(undefined, "balance.category.cash", "cash", t) + ).toBe("Liquidités"); + }); + + it("falls back to the raw key when no translation exists", () => { + expect( + renderCategoryLabel(null, "balance.category.unknown", "unknown", t) + ).toBe("unknown"); + }); + + it("returns a trimmed custom_label (leading/trailing spaces removed)", () => { + expect( + renderCategoryLabel(" Épargne ", "balance.category.cash", "cash", t) + ).toBe("Épargne"); + }); +}); + +describe("renderCategoryLabelFromCategory", () => { + it("maps the BalanceCategory shape", () => { + expect( + renderCategoryLabelFromCategory( + { custom_label: null, i18n_key: "balance.category.cash", key: "cash" }, + t + ) + ).toBe("Liquidités"); + expect( + renderCategoryLabelFromCategory( + { custom_label: "X", i18n_key: "balance.category.cash", key: "cash" }, + t + ) + ).toBe("X"); + }); +}); + +describe("renderCategoryLabelFromAccount", () => { + it("maps the JOIN-prefixed account shape", () => { + expect( + renderCategoryLabelFromAccount( + { + category_custom_label: null, + category_i18n_key: "balance.category.cash", + category_key: "cash", + }, + t + ) + ).toBe("Liquidités"); + expect( + renderCategoryLabelFromAccount( + { + category_custom_label: "Compte X", + category_i18n_key: "balance.category.cash", + category_key: "cash", + }, + t + ) + ).toBe("Compte X"); + }); +}); diff --git a/src/utils/renderCategoryLabel.ts b/src/utils/renderCategoryLabel.ts new file mode 100644 index 0000000..9b86c62 --- /dev/null +++ b/src/utils/renderCategoryLabel.ts @@ -0,0 +1,73 @@ +// renderCategoryLabel — single source of truth for how a balance category's +// label is shown in the UI (Bilan axe véhicule, Étape 1 / Issue #203). +// +// Precedence: an explicit `custom_label` (trimmed, non-empty) always wins; +// otherwise fall back to the i18n translation of `i18n_key`, and finally to +// the raw `key` if no translation exists. This is what makes category +// renaming (which now writes `custom_label`, never `i18n_key`) coexist with +// the seeded translations and fixes bug I. +// +// The 5 call sites carry the same three scalars under DIFFERENT field names: +// - BalanceCategory → custom_label / i18n_key / key +// - BalanceAccountWithCategory → category_custom_label / category_i18n_key / category_key +// - AccountLatestSnapshot → category_custom_label / category_i18n_key / category_key +// To avoid a shape mismatch (the "affichage partiel" caveat), the core helper +// takes explicit scalar args; thin adapters normalize each object shape. + +/** Minimal i18n translate signature (matches react-i18next's `t`). */ +type TranslateFn = ( + key: string, + options?: { defaultValue?: string } +) => string; + +/** + * Resolve a category's display label from its three label scalars. + * + * @param customLabel User-facing override (`custom_label`). NULL/blank ignored. + * @param i18nKey Translation key (`i18n_key`), e.g. 'balance.category.cash'. + * @param key Stable lookup key (`key`), used as the last-resort label. + * @param t Translate function. + */ +export function renderCategoryLabel( + customLabel: string | null | undefined, + i18nKey: string, + key: string, + t: TranslateFn +): string { + const trimmed = customLabel?.trim(); + if (trimmed) return trimmed; + return t(i18nKey, { defaultValue: key }); +} + +/** Adapter for a `BalanceCategory`-shaped object. */ +export function renderCategoryLabelFromCategory( + cat: { + custom_label?: string | null; + i18n_key: string; + key: string; + }, + t: TranslateFn +): string { + return renderCategoryLabel(cat.custom_label, cat.i18n_key, cat.key, t); +} + +/** + * Adapter for the JOIN-prefixed shape carried by `BalanceAccountWithCategory` + * and `AccountLatestSnapshot` (`category_custom_label` / `category_i18n_key` / + * `category_key`). + */ +export function renderCategoryLabelFromAccount( + acc: { + category_custom_label?: string | null; + category_i18n_key: string; + category_key: string; + }, + t: TranslateFn +): string { + return renderCategoryLabel( + acc.category_custom_label, + acc.category_i18n_key, + acc.category_key, + t + ); +}