diff --git a/frontend/src/components/analysis/BillBreakdown.tsx b/frontend/src/components/analysis/BillBreakdown.tsx index e9875a8..f30e83d 100644 --- a/frontend/src/components/analysis/BillBreakdown.tsx +++ b/frontend/src/components/analysis/BillBreakdown.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { InfoTooltip } from '@/components/ui/InfoTooltip' import { MONTH_LABELS } from '@/lib/analysis' -import { formatCurrency, formatNumber, BILL_TOOLTIPS, NEM_TOOLTIPS } from './formatters' +import { formatCurrency, formatNumber } from './formatters' import type { runAnnualSimulation } from '@/lib/billingEngine' type SimulationMonth = ReturnType['months'][number] @@ -93,49 +93,49 @@ export function BillBreakdown({

- {t('billBreakdown.fields.energy')} + {t('billBreakdown.fields.energy')}

{formatCurrency(selectedMonth.baselineBill.energy)}

- {t('billBreakdown.fields.capacity')} + {t('billBreakdown.fields.capacity')}

{formatCurrency(selectedMonth.baselineBill.capacity)}

- {t('billBreakdown.fields.network')} + {t('billBreakdown.fields.network')}

{formatCurrency(selectedMonth.baselineBill.network)}

- {t('billBreakdown.fields.retail')} + {t('billBreakdown.fields.retail')}

{formatCurrency(selectedMonth.baselineBill.retail)}

- {t('billBreakdown.fields.afa')} + {t('billBreakdown.fields.afa')}

{formatCurrency(selectedMonth.baselineBill.afa)}

- {t('billBreakdown.fields.eeiRebate')} + {t('billBreakdown.fields.eeiRebate')}

-{formatCurrency(selectedMonth.baselineBill.eeiRebate)}

- {t('billBreakdown.fields.reFund')} + {t('billBreakdown.fields.reFund')}

{formatCurrency(selectedMonth.baselineBill.reFund)}

- {t('billBreakdown.fields.sst')} + {t('billBreakdown.fields.sst')}

{formatCurrency(selectedMonth.baselineBill.sst)}

@@ -159,73 +159,76 @@ export function BillBreakdown({

- {t('billBreakdown.fields.billableKwh')} + {t('billBreakdown.fields.billableKwh')}{' '} +

{formatNumber(selectedMonth.billableKwh, 'kWh')}

- {t('billBreakdown.fields.creditUsed')} + {t('billBreakdown.fields.creditUsed')}

{formatNumber(selectedMonth.creditUsed, 'kWh')}

- {t('billBreakdown.fields.creditBalance')} + {t('billBreakdown.fields.creditBalance')}{' '} +

{formatNumber(selectedMonth.creditBalance, 'kWh')}

- {t('billBreakdown.fields.creditForfeited')} + {t('billBreakdown.fields.creditForfeited')}{' '} +

{formatNumber(selectedMonth.creditForfeited, 'kWh')}

- {t('billBreakdown.fields.energy')} + {t('billBreakdown.fields.energy')}

{formatCurrency(selectedMonth.nemBill.energy)}

- {t('billBreakdown.fields.retail')} + {t('billBreakdown.fields.retail')}

{formatCurrency(selectedMonth.nemBill.retail)}

- {t('billBreakdown.fields.capacity')} + {t('billBreakdown.fields.capacity')}

{formatCurrency(selectedMonth.nemBill.capacity)}

- {t('billBreakdown.fields.afa')} + {t('billBreakdown.fields.afa')}

{formatCurrency(selectedMonth.nemBill.afa)}

- {t('billBreakdown.fields.network')} + {t('billBreakdown.fields.network')}

{formatCurrency(selectedMonth.nemBill.network)}

- {t('billBreakdown.fields.eeiRebate')} + {t('billBreakdown.fields.eeiRebate')}

-{formatCurrency(selectedMonth.nemBill.eeiRebate)}

- {t('billBreakdown.fields.reFund')} + {t('billBreakdown.fields.reFund')}

{formatCurrency(selectedMonth.nemBill.reFund)}

- {t('billBreakdown.fields.sst')} + {t('billBreakdown.fields.sst')}

{formatCurrency(selectedMonth.nemBill.sst)}

diff --git a/frontend/src/components/analysis/formatters.ts b/frontend/src/components/analysis/formatters.ts index cf61825..f6d4027 100644 --- a/frontend/src/components/analysis/formatters.ts +++ b/frontend/src/components/analysis/formatters.ts @@ -1,4 +1,4 @@ /** * Re-exports shared helpers */ -export { formatCurrency, formatNumber, formatTooltipCurrency, BILL_TOOLTIPS, NEM_TOOLTIPS } from '@/lib/formatters' +export { formatCurrency, formatNumber, formatTooltipCurrency } from '@/lib/formatters' diff --git a/frontend/src/hooks/useLocale.tsx b/frontend/src/hooks/useLocale.tsx index 1b55c10..b598bc2 100644 --- a/frontend/src/hooks/useLocale.tsx +++ b/frontend/src/hooks/useLocale.tsx @@ -14,6 +14,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, ty import i18n, { DEFAULT_LOCALE, isSupportedLocale, LOCALE_STORAGE_KEY, type SupportedLocale } from '@/lib/i18n' import { useAuth } from '@/hooks/useAuth' import { authClient } from '@/lib/auth-client' +import { setFormatterLocale } from '@/lib/formatters' /** Value exposed by `useLocale`. */ type LocaleContextValue = { @@ -88,6 +89,12 @@ export function LocaleProvider({ children }: { children: ReactNode }) { [user] ) + // Keeps the shared Intl formatters in step with the active locale; without this + // currency and number grouping stay pinned to en-MY regardless of the UI language. + useEffect(() => { + setFormatterLocale(locale) + }, [locale]) + return {children} } diff --git a/frontend/src/lib/formatters.ts b/frontend/src/lib/formatters.ts index 102cb07..0c8d2f4 100644 --- a/frontend/src/lib/formatters.ts +++ b/frontend/src/lib/formatters.ts @@ -1,12 +1,36 @@ -const currencyFormatter = new Intl.NumberFormat('en-MY', { - style: 'currency', - currency: 'MYR', - maximumFractionDigits: 2 -}) +import { LOCALE_TO_INTL, type SupportedLocale } from './locales' -const numberFormatter = new Intl.NumberFormat('en-MY', { - maximumFractionDigits: 1 -}) +// Rebuilt whenever the UI locale changes rather than passed per call: these +// formatters have ~100 call sites, and threading a locale argument through all +// of them would be a far larger change than the behaviour warrants. +let currencyFormatter = buildCurrencyFormatter('en') +let numberFormatter = buildNumberFormatter('en') + +function buildCurrencyFormatter(locale: SupportedLocale) { + return new Intl.NumberFormat(LOCALE_TO_INTL[locale], { + style: 'currency', + currency: 'MYR', + maximumFractionDigits: 2 + }) +} + +function buildNumberFormatter(locale: SupportedLocale) { + return new Intl.NumberFormat(LOCALE_TO_INTL[locale], { + maximumFractionDigits: 1 + }) +} + +/** + * Points the shared number/currency formatters at a locale. Called by + * `LocaleProvider` on mount and on every locale change, so `Intl` grouping and + * currency placement follow the user's language instead of being pinned to en-MY. + * + * @param locale - Active UI locale + */ +export function setFormatterLocale(locale: SupportedLocale) { + currencyFormatter = buildCurrencyFormatter(locale) + numberFormatter = buildNumberFormatter(locale) +} /** * Formats a value as Malaysian Ringgit (`RM 1,234.56`). Returns `'N/A'` for `null`. @@ -49,23 +73,3 @@ export function formatTooltipCurrency(value: unknown) { return 'N/A' } - -/** TNB bill component descriptions used as tooltip text in the analysis breakdown. */ -export const BILL_TOOLTIPS: Record = { - energy: "The base electricity charge, calculated from your kWh usage at TNB's tiered rates.", - capacity: 'A fixed charge based on your connection capacity, applied to usage above 600 kWh.', - network: 'Covers the cost of maintaining the electricity grid that delivers power to your home.', - retail: 'An additional surcharge applied to usage above 600 kWh.', - afa: 'Automatic Fuel Adjustment: a government-set surcharge (or rebate) that reflects fuel cost changes.', - eeiRebate: 'Energy Efficiency Incentive: a rebate that rewards lower electricity consumption.', - reFund: "Renewable Energy Fund: a 1.6% levy that funds Malaysia's renewable energy development.", - sst: 'Sales and Service Tax (8%), applied only when monthly usage exceeds 600 kWh.' -} - -/** NEM credit-flow descriptions used as tooltip text in the analysis breakdown. */ -export const NEM_TOOLTIPS: Record = { - billableKwh: 'Your consumption minus solar generation. This is what TNB actually charges you for.', - creditUsed: "Excess solar credits from previous months applied to reduce this month's bill.", - creditBalance: "Unused solar credits carried forward to offset future months' bills.", - creditForfeited: 'Credits that expired at year-end (December). NEM credits cannot be carried into the next year.' -} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 5cf64f7..04f0801 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -49,18 +49,24 @@ import zhSettings from '@/locales/zh/settings.json' import zhNotifications from '@/locales/zh/notifications.json' import zhChat from '@/locales/zh/chat.json' import zhPrivacy from '@/locales/zh/privacy.json' +import { DEFAULT_LOCALE, SUPPORTED_LOCALES, LOCALE_STORAGE_KEY, type SupportedLocale } from './locales' -/** Locale codes the app ships translations for. First entry is the i18next fallback. */ -export const SUPPORTED_LOCALES = ['en', 'ms', 'zh'] as const +// Re-exported so existing importers keep working; the declarations live in a +// side-effect-free module because this one calls i18n.init() at module scope. +export { + SUPPORTED_LOCALES, + DEFAULT_LOCALE, + LOCALE_STORAGE_KEY, + LOCALE_TO_INTL, + isSupportedLocale, + type SupportedLocale +} from './locales' -/** Union of every locale code in {@link SUPPORTED_LOCALES}. */ -export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number] +/** Locale codes the app ships translations for. First entry is the i18next fallback. */ /** Default locale used before the language detector resolves. */ -export const DEFAULT_LOCALE: SupportedLocale = 'en' /** localStorage key i18next reads/writes the active locale to. */ -export const LOCALE_STORAGE_KEY = 'locale' /** Human-readable label shown in the language switcher, keyed by locale code. */ export const LOCALE_LABELS: Record = { @@ -73,11 +79,6 @@ export const LOCALE_LABELS: Record = { * BCP 47 tags passed to `Intl.NumberFormat` / `Intl.DateTimeFormat`. * `zh-Hans-MY` pins the Chinese script to Simplified for the Malaysian audience. */ -export const LOCALE_TO_INTL: Record = { - en: 'en-MY', - ms: 'ms-MY', - zh: 'zh-Hans-MY' -} const resources = { en: { @@ -139,9 +140,6 @@ const resources = { * @param value - Candidate locale string from an untrusted source * @returns `true` when `value` is one of {@link SUPPORTED_LOCALES}, narrowing it to `SupportedLocale` */ -export function isSupportedLocale(value: string | null | undefined): value is SupportedLocale { - return value !== null && value !== undefined && (SUPPORTED_LOCALES as readonly string[]).includes(value) -} i18n .use(LanguageDetector) diff --git a/frontend/src/lib/locales.ts b/frontend/src/lib/locales.ts new file mode 100644 index 0000000..0933c05 --- /dev/null +++ b/frontend/src/lib/locales.ts @@ -0,0 +1,40 @@ +/** + * Locale constants, kept free of side effects. + * + * `i18n.ts` calls `i18n.init()` at module scope, so anything importing from it + * pulls that initialisation in. Modules that only need the locale codes — the + * shared `Intl` formatters, for instance — import from here instead, and tests + * that mock i18n are not forced to load the real thing. + */ + +/** Locale codes the UI ships translations for. */ +export const SUPPORTED_LOCALES = ['en', 'ms', 'zh'] as const + +/** Union of every locale code in {@link SUPPORTED_LOCALES}. */ +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number] + +/** Locale used before the user expresses a preference. */ +export const DEFAULT_LOCALE: SupportedLocale = 'en' + +/** localStorage key holding the persisted locale choice. */ +export const LOCALE_STORAGE_KEY = 'locale' + +/** + * BCP 47 tags passed to `Intl.NumberFormat` / `Intl.DateTimeFormat`. + * `zh-Hans-MY` pins the Chinese script to Simplified for the Malaysian audience. + */ +export const LOCALE_TO_INTL: Record = { + en: 'en-MY', + ms: 'ms-MY', + zh: 'zh-Hans-MY' +} + +/** + * Type guard for locale codes coming from external sources (URL params, localStorage, Better Auth user record). + * + * @param value - Candidate locale string from an untrusted source + * @returns `true` when `value` is one of {@link SUPPORTED_LOCALES}, narrowing it to `SupportedLocale` + */ +export function isSupportedLocale(value: string | null | undefined): value is SupportedLocale { + return value !== null && value !== undefined && (SUPPORTED_LOCALES as readonly string[]).includes(value) +} diff --git a/frontend/src/locales/en/analysis.json b/frontend/src/locales/en/analysis.json index 36c5a2c..52db8a6 100644 --- a/frontend/src/locales/en/analysis.json +++ b/frontend/src/locales/en/analysis.json @@ -303,6 +303,22 @@ "creditBalance": "Credit Balance", "creditForfeited": "Credit Forfeited", "youSave": "You save {{amount}} this month" + }, + "tooltips": { + "energy": "The base electricity charge, calculated from your kWh usage at TNB's tiered rates.", + "capacity": "A fixed charge based on your connection capacity, applied to usage above 600 kWh.", + "network": "Covers the cost of maintaining the electricity grid that delivers power to your home.", + "retail": "An additional surcharge applied to usage above 600 kWh.", + "afa": "Automatic Fuel Adjustment: a government-set surcharge (or rebate) that reflects fuel cost changes.", + "eeiRebate": "Energy Efficiency Incentive: a rebate that rewards lower electricity consumption.", + "reFund": "Renewable Energy Fund: a 1.6% levy that funds Malaysia's renewable energy development.", + "sst": "Sales and Service Tax (8%), applied only when monthly usage exceeds 600 kWh." + }, + "nemTooltips": { + "billableKwh": "Your consumption minus solar generation. This is what TNB actually charges you for.", + "creditUsed": "Excess solar credits from previous months applied to reduce this month's bill.", + "creditBalance": "Unused solar credits carried forward to offset future months' bills.", + "creditForfeited": "Credits that expired at year-end (December). NEM credits cannot be carried into the next year." } }, "monthTable": { diff --git a/frontend/src/locales/ms/analysis.json b/frontend/src/locales/ms/analysis.json index 6c18a7f..02a8335 100644 --- a/frontend/src/locales/ms/analysis.json +++ b/frontend/src/locales/ms/analysis.json @@ -303,6 +303,22 @@ "creditBalance": "Baki Kredit", "creditForfeited": "Kredit Dilucutkan", "youSave": "Anda jimat {{amount}} bulan ini" + }, + "tooltips": { + "energy": "Caj elektrik asas, dikira daripada penggunaan kWh anda pada kadar berperingkat TNB.", + "capacity": "Caj tetap berdasarkan kapasiti sambungan anda, dikenakan untuk penggunaan melebihi 600 kWh.", + "network": "Menampung kos penyelenggaraan grid elektrik yang menyalurkan kuasa ke rumah anda.", + "retail": "Surcaj tambahan yang dikenakan untuk penggunaan melebihi 600 kWh.", + "afa": "Pelarasan Bahan Api Automatik: surcaj (atau rebat) yang ditetapkan kerajaan mengikut perubahan kos bahan api.", + "eeiRebate": "Insentif Kecekapan Tenaga: rebat yang memberi ganjaran kepada penggunaan elektrik yang lebih rendah.", + "reFund": "Dana Tenaga Boleh Baharu: levi 1.6% yang membiayai pembangunan tenaga boleh baharu Malaysia.", + "sst": "Cukai Jualan dan Perkhidmatan (8%), dikenakan hanya apabila penggunaan bulanan melebihi 600 kWh." + }, + "nemTooltips": { + "billableKwh": "Penggunaan anda tolak penjanaan solar. Inilah jumlah yang sebenarnya dicaj oleh TNB.", + "creditUsed": "Kredit solar berlebihan daripada bulan sebelumnya yang digunakan untuk mengurangkan bil bulan ini.", + "creditBalance": "Kredit solar yang belum digunakan dan dibawa ke hadapan untuk mengimbangi bil bulan akan datang.", + "creditForfeited": "Kredit yang luput pada hujung tahun (Disember). Kredit NEM tidak boleh dibawa ke tahun berikutnya." } }, "monthTable": { diff --git a/frontend/src/locales/zh/analysis.json b/frontend/src/locales/zh/analysis.json index a0e7023..301c9c6 100644 --- a/frontend/src/locales/zh/analysis.json +++ b/frontend/src/locales/zh/analysis.json @@ -303,6 +303,22 @@ "creditBalance": "积分余额", "creditForfeited": "已失效积分", "youSave": "本月节省 {{amount}}" + }, + "tooltips": { + "energy": "基本电费,按您的 kWh 用量以国能(TNB)的阶梯电价计算。", + "capacity": "依据您的接电容量收取的固定费用,适用于超过 600 kWh 的用量。", + "network": "用于维护将电力输送到您家的电网成本。", + "retail": "针对超过 600 kWh 用量收取的额外附加费。", + "afa": "燃料自动调整:由政府设定的附加费(或回扣),反映燃料成本的变动。", + "eeiRebate": "能源效率奖励:对较低用电量给予的回扣。", + "reFund": "可再生能源基金:1.6% 的征费,用于资助马来西亚的可再生能源发展。", + "sst": "销售与服务税(8%),仅在每月用量超过 600 kWh 时征收。" + }, + "nemTooltips": { + "billableKwh": "您的用电量减去太阳能发电量,这才是国能实际向您收费的部分。", + "creditUsed": "前几个月结余的太阳能积分,用于抵扣本月账单。", + "creditBalance": "尚未使用的太阳能积分,可结转用于抵扣往后月份的账单。", + "creditForfeited": "于年底(12 月)到期的积分。NEM 积分无法结转至下一年。" } }, "monthTable": {