diff --git a/client/components/FormatSync.tsx b/client/components/FormatSync.tsx new file mode 100644 index 0000000..94d3cdd --- /dev/null +++ b/client/components/FormatSync.tsx @@ -0,0 +1,37 @@ +import { useLayoutEffect, useState, type ReactNode } from 'react'; +import { useSettings } from '@/hooks/useQueries'; +import { setMoneyFormat, getActiveCurrency } from '@/lib/money'; +import { setDateFormat, getActiveDateFormat } from '@/lib/utils'; + +/** + * Reconciles the server's currency / date_format preferences into the + * module-level formatters (money.ts / utils.ts). Those are read synchronously + * from localStorage at load, so same-device use is already correct with no + * flash; this only does work when the server value differs from what's cached — + * i.e. a first login on a new device, or a change made on another device. In + * that case it bumps a `key` so the routed subtree re-renders once with the + * right formatting (cheap; these settings change rarely). Normal on-device + * changes write through immediately, so this never forces a disruptive remount + * mid-session. + */ +export function FormatSync({ children }: { children: ReactNode }) { + const { data } = useSettings(); + const [version, setVersion] = useState(0); + + useLayoutEffect(() => { + if (!data) return; + const s = data as Record; + let changed = false; + if (typeof s.currency === 'string' && s.currency !== getActiveCurrency()) { + setMoneyFormat(s.currency); + changed = true; + } + if (typeof s.date_format === 'string' && s.date_format !== getActiveDateFormat()) { + setDateFormat(s.date_format); + changed = true; + } + if (changed) setVersion((v) => v + 1); + }, [data]); + + return
{children}
; +} diff --git a/client/components/layout/Layout.tsx b/client/components/layout/Layout.tsx index 1069128..fbea198 100644 --- a/client/components/layout/Layout.tsx +++ b/client/components/layout/Layout.tsx @@ -3,6 +3,7 @@ import { Link, Outlet, useLocation } from 'react-router-dom'; import AppNavigation from './Sidebar'; import { api } from '@/api'; import PageTransition from '@/components/PageTransition'; +import { FormatSync } from '@/components/FormatSync'; function SimplefinBadge() { const [enabled, setEnabled] = useState(false); @@ -38,7 +39,9 @@ export default function Layout({ mainContentId }: { mainContentId?: string }) { id={mainContentId} > - + + + diff --git a/client/lib/money.ts b/client/lib/money.ts index 14344b3..b10fb70 100644 --- a/client/lib/money.ts +++ b/client/lib/money.ts @@ -35,6 +35,43 @@ type CentsInput = Cents | string | null | undefined; const DASH = '—'; +// ── Active display currency (a user preference; display-only, no conversion) ── +// Read synchronously from localStorage at module load so the very first paint is +// already in the right currency (the settings query resolves later; FormatSync +// reconciles server→local). Amounts are entered/stored in a single currency — +// this only changes the symbol/locale used to render them. +const CURRENCY_LOCALES: Record = { + USD: 'en-US', EUR: 'en-IE', GBP: 'en-GB', CAD: 'en-CA', + AUD: 'en-AU', JPY: 'ja-JP', INR: 'en-IN', CHF: 'de-CH', +}; +const CURRENCY_STORAGE_KEY = 'bt-currency'; +export const SUPPORTED_CURRENCIES = Object.keys(CURRENCY_LOCALES); + +function loadCurrency(): string { + try { + const c = localStorage.getItem(CURRENCY_STORAGE_KEY); + if (c && CURRENCY_LOCALES[c]) return c; + } catch { /* localStorage unavailable */ } + return 'USD'; +} + +let activeCurrency = loadCurrency(); +let activeLocale = CURRENCY_LOCALES[activeCurrency] ?? 'en-US'; + +/** Set the active display currency + persist it (called from Settings + FormatSync). */ +export function setMoneyFormat(currency: string): void { + if (!CURRENCY_LOCALES[currency]) return; + activeCurrency = currency; + activeLocale = CURRENCY_LOCALES[currency]!; + try { + localStorage.setItem(CURRENCY_STORAGE_KEY, currency); + } catch { /* localStorage unavailable */ } +} + +export function getActiveCurrency(): string { + return activeCurrency; +} + function toNumber(value: unknown): number { const n = Number(value); if (!Number.isFinite(n)) return 0; @@ -54,9 +91,9 @@ export function formatUSD( { whole = false, dash = false }: { whole?: boolean; dash?: boolean } = {}, ): string { if (dash && isBlank(dollars)) return DASH; - return toNumber(dollars).toLocaleString('en-US', { + return toNumber(dollars).toLocaleString(activeLocale, { style: 'currency', - currency: 'USD', + currency: activeCurrency, minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: whole ? 0 : 2, }); @@ -77,13 +114,13 @@ export function formatUSDWhole( */ export function formatCentsUSD( cents: CentsInput, - { signed = false, dash = false, currency = 'USD' }: { signed?: boolean; dash?: boolean; currency?: string } = {}, + { signed = false, dash = false, currency }: { signed?: boolean; dash?: boolean; currency?: string } = {}, ): string { if (dash && isBlank(cents)) return DASH; const c = toNumber(cents); - const body = (Math.abs(c) / 100).toLocaleString('en-US', { + const body = (Math.abs(c) / 100).toLocaleString(activeLocale, { style: 'currency', - currency: currency || 'USD', + currency: currency || activeCurrency, minimumFractionDigits: 2, maximumFractionDigits: 2, }); diff --git a/client/lib/utils.ts b/client/lib/utils.ts index 31f4cc7..5965a7b 100644 --- a/client/lib/utils.ts +++ b/client/lib/utils.ts @@ -20,10 +20,44 @@ export function errMessage(err: unknown, fallback = 'Something went wrong'): str return err instanceof Error && err.message ? err.message : fallback; } +// ── Active date format (a display-only user preference) ── +// localStorage-first so the first paint is already correct (FormatSync reconciles +// server→local). Input is a stored YYYY-MM-DD string. +type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD'; +const DATE_FORMAT_STORAGE_KEY = 'bt-date-format'; +const VALID_DATE_FORMATS: DateFormat[] = ['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD']; + +function loadDateFormat(): DateFormat { + try { + const f = localStorage.getItem(DATE_FORMAT_STORAGE_KEY); + if (f && (VALID_DATE_FORMATS as string[]).includes(f)) return f as DateFormat; + } catch { /* localStorage unavailable */ } + return 'MM/DD/YYYY'; +} + +let activeDateFormat: DateFormat = loadDateFormat(); + +/** Set the active date format + persist it (called from Settings + FormatSync). */ +export function setDateFormat(format: string): void { + if (!(VALID_DATE_FORMATS as string[]).includes(format)) return; + activeDateFormat = format as DateFormat; + try { + localStorage.setItem(DATE_FORMAT_STORAGE_KEY, format); + } catch { /* localStorage unavailable */ } +} + +export function getActiveDateFormat(): string { + return activeDateFormat; +} + export function fmtDate(dateStr: string | null | undefined): string { if (!dateStr) return '—'; const [y = '', m = '', d = ''] = dateStr.split('-'); - return `${parseInt(m)}/${parseInt(d)}/${y}`; + switch (activeDateFormat) { + case 'DD/MM/YYYY': return `${parseInt(d)}/${parseInt(m)}/${y}`; + case 'YYYY-MM-DD': return `${y}-${m}-${d}`; + default: return `${parseInt(m)}/${parseInt(d)}/${y}`; + } } export function localDateString(date: Date = new Date()): string { diff --git a/client/pages/SettingsPage.tsx b/client/pages/SettingsPage.tsx index 316dc9d..9dde636 100644 --- a/client/pages/SettingsPage.tsx +++ b/client/pages/SettingsPage.tsx @@ -4,7 +4,8 @@ import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { AlertCircle, Moon, Monitor, RefreshCw, Sun, Users, HelpCircle } from 'lucide-react'; import { api } from '@/api'; -import { cn, errMessage, settingEnabled } from '@/lib/utils'; +import { cn, errMessage, settingEnabled, setDateFormat } from '@/lib/utils'; +import { setMoneyFormat } from '@/lib/money'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { CalendarFeedManager } from '@/components/CalendarFeedManager'; @@ -441,9 +442,12 @@ export default function SettingsPage() { {/* General */} - - - { setMoneyFormat(v); set('currency', v); }}> @@ -452,12 +456,16 @@ export default function SettingsPage() { EUR — Euro GBP — British Pound CAD — Canadian Dollar + AUD — Australian Dollar + CHF — Swiss Franc + JPY — Japanese Yen + INR — Indian Rupee - { setDateFormat(v); set('date_format', v); }}>