Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 24 additions & 21 deletions frontend/src/components/analysis/BillBreakdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof runAnnualSimulation>['months'][number]
Expand Down Expand Up @@ -93,49 +93,49 @@ export function BillBreakdown({
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-muted-foreground">
{t('billBreakdown.fields.energy')} <InfoTooltip text={BILL_TOOLTIPS.energy} />
{t('billBreakdown.fields.energy')} <InfoTooltip text={t('billBreakdown.tooltips.energy')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.baselineBill.energy)}</p>
</div>
<div>
<p className="text-muted-foreground">
{t('billBreakdown.fields.capacity')} <InfoTooltip text={BILL_TOOLTIPS.capacity} />
{t('billBreakdown.fields.capacity')} <InfoTooltip text={t('billBreakdown.tooltips.capacity')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.baselineBill.capacity)}</p>
</div>
<div>
<p className="text-muted-foreground">
{t('billBreakdown.fields.network')} <InfoTooltip text={BILL_TOOLTIPS.network} />
{t('billBreakdown.fields.network')} <InfoTooltip text={t('billBreakdown.tooltips.network')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.baselineBill.network)}</p>
</div>
<div>
<p className="text-muted-foreground">
{t('billBreakdown.fields.retail')} <InfoTooltip text={BILL_TOOLTIPS.retail} />
{t('billBreakdown.fields.retail')} <InfoTooltip text={t('billBreakdown.tooltips.retail')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.baselineBill.retail)}</p>
</div>
<div>
<p className="text-muted-foreground">
{t('billBreakdown.fields.afa')} <InfoTooltip text={BILL_TOOLTIPS.afa} />
{t('billBreakdown.fields.afa')} <InfoTooltip text={t('billBreakdown.tooltips.afa')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.baselineBill.afa)}</p>
</div>
<div>
<p className="text-muted-foreground">
{t('billBreakdown.fields.eeiRebate')} <InfoTooltip text={BILL_TOOLTIPS.eeiRebate} />
{t('billBreakdown.fields.eeiRebate')} <InfoTooltip text={t('billBreakdown.tooltips.eeiRebate')} />
</p>
<p className="font-semibold">-{formatCurrency(selectedMonth.baselineBill.eeiRebate)}</p>
</div>
<div>
<p className="text-muted-foreground">
{t('billBreakdown.fields.reFund')} <InfoTooltip text={BILL_TOOLTIPS.reFund} />
{t('billBreakdown.fields.reFund')} <InfoTooltip text={t('billBreakdown.tooltips.reFund')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.baselineBill.reFund)}</p>
</div>
<div>
<p className="text-muted-foreground">
{t('billBreakdown.fields.sst')} <InfoTooltip text={BILL_TOOLTIPS.sst} />
{t('billBreakdown.fields.sst')} <InfoTooltip text={t('billBreakdown.tooltips.sst')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.baselineBill.sst)}</p>
</div>
Expand All @@ -159,73 +159,76 @@ export function BillBreakdown({
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-emerald-900/70 dark:text-emerald-100/70">
{t('billBreakdown.fields.billableKwh')} <InfoTooltip text={NEM_TOOLTIPS.billableKwh} />
{t('billBreakdown.fields.billableKwh')}{' '}
<InfoTooltip text={t('billBreakdown.nemTooltips.billableKwh')} />
</p>
<p className="font-semibold">{formatNumber(selectedMonth.billableKwh, 'kWh')}</p>
</div>
<div>
<p className="text-emerald-900/70 dark:text-emerald-100/70">
{t('billBreakdown.fields.creditUsed')} <InfoTooltip text={NEM_TOOLTIPS.creditUsed} />
{t('billBreakdown.fields.creditUsed')} <InfoTooltip text={t('billBreakdown.nemTooltips.creditUsed')} />
</p>
<p className="font-semibold">{formatNumber(selectedMonth.creditUsed, 'kWh')}</p>
</div>
<div>
<p className="text-emerald-900/70 dark:text-emerald-100/70">
{t('billBreakdown.fields.creditBalance')} <InfoTooltip text={NEM_TOOLTIPS.creditBalance} />
{t('billBreakdown.fields.creditBalance')}{' '}
<InfoTooltip text={t('billBreakdown.nemTooltips.creditBalance')} />
</p>
<p className="font-semibold">{formatNumber(selectedMonth.creditBalance, 'kWh')}</p>
</div>
<div>
<p className="text-emerald-900/70 dark:text-emerald-100/70">
{t('billBreakdown.fields.creditForfeited')} <InfoTooltip text={NEM_TOOLTIPS.creditForfeited} />
{t('billBreakdown.fields.creditForfeited')}{' '}
<InfoTooltip text={t('billBreakdown.nemTooltips.creditForfeited')} />
</p>
<p className="font-semibold">{formatNumber(selectedMonth.creditForfeited, 'kWh')}</p>
</div>
<div>
<p className="text-emerald-900/70 dark:text-emerald-100/70">
{t('billBreakdown.fields.energy')} <InfoTooltip text={BILL_TOOLTIPS.energy} />
{t('billBreakdown.fields.energy')} <InfoTooltip text={t('billBreakdown.tooltips.energy')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.nemBill.energy)}</p>
</div>
<div>
<p className="text-emerald-900/70 dark:text-emerald-100/70">
{t('billBreakdown.fields.retail')} <InfoTooltip text={BILL_TOOLTIPS.retail} />
{t('billBreakdown.fields.retail')} <InfoTooltip text={t('billBreakdown.tooltips.retail')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.nemBill.retail)}</p>
</div>
<div>
<p className="text-emerald-900/70 dark:text-emerald-100/70">
{t('billBreakdown.fields.capacity')} <InfoTooltip text={BILL_TOOLTIPS.capacity} />
{t('billBreakdown.fields.capacity')} <InfoTooltip text={t('billBreakdown.tooltips.capacity')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.nemBill.capacity)}</p>
</div>
<div>
<p className="text-emerald-900/70 dark:text-emerald-100/70">
{t('billBreakdown.fields.afa')} <InfoTooltip text={BILL_TOOLTIPS.afa} />
{t('billBreakdown.fields.afa')} <InfoTooltip text={t('billBreakdown.tooltips.afa')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.nemBill.afa)}</p>
</div>
<div>
<p className="text-emerald-900/70 dark:text-emerald-100/70">
{t('billBreakdown.fields.network')} <InfoTooltip text={BILL_TOOLTIPS.network} />
{t('billBreakdown.fields.network')} <InfoTooltip text={t('billBreakdown.tooltips.network')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.nemBill.network)}</p>
</div>
<div>
<p className="text-emerald-900/70 dark:text-emerald-100/70">
{t('billBreakdown.fields.eeiRebate')} <InfoTooltip text={BILL_TOOLTIPS.eeiRebate} />
{t('billBreakdown.fields.eeiRebate')} <InfoTooltip text={t('billBreakdown.tooltips.eeiRebate')} />
</p>
<p className="font-semibold">-{formatCurrency(selectedMonth.nemBill.eeiRebate)}</p>
</div>
<div>
<p className="text-emerald-900/70 dark:text-emerald-100/70">
{t('billBreakdown.fields.reFund')} <InfoTooltip text={BILL_TOOLTIPS.reFund} />
{t('billBreakdown.fields.reFund')} <InfoTooltip text={t('billBreakdown.tooltips.reFund')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.nemBill.reFund)}</p>
</div>
<div>
<p className="text-emerald-900/70 dark:text-emerald-100/70">
{t('billBreakdown.fields.sst')} <InfoTooltip text={BILL_TOOLTIPS.sst} />
{t('billBreakdown.fields.sst')} <InfoTooltip text={t('billBreakdown.tooltips.sst')} />
</p>
<p className="font-semibold">{formatCurrency(selectedMonth.nemBill.sst)}</p>
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/analysis/formatters.ts
Original file line number Diff line number Diff line change
@@ -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'
7 changes: 7 additions & 0 deletions frontend/src/hooks/useLocale.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 <LocaleContext.Provider value={{ locale, setLocale }}>{children}</LocaleContext.Provider>
}

Expand Down
60 changes: 32 additions & 28 deletions frontend/src/lib/formatters.ts
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down Expand Up @@ -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<string, string> = {
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<string, string> = {
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.'
}
26 changes: 12 additions & 14 deletions frontend/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SupportedLocale, string> = {
Expand All @@ -73,11 +79,6 @@ export const LOCALE_LABELS: Record<SupportedLocale, string> = {
* 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<SupportedLocale, string> = {
en: 'en-MY',
ms: 'ms-MY',
zh: 'zh-Hans-MY'
}

const resources = {
en: {
Expand Down Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/lib/locales.ts
Original file line number Diff line number Diff line change
@@ -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<SupportedLocale, string> = {
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)
}
16 changes: 16 additions & 0 deletions frontend/src/locales/en/analysis.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading