From 0fa80a77ef82c1cbe2159eb37874db8d79efc83c Mon Sep 17 00:00:00 2001 From: null Date: Sun, 5 Jul 2026 15:54:19 -0500 Subject: [PATCH] feat(settings): make Currency + Date-format real (plan Tier 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Currency/Date-format dropdowns were decorative (nothing read them). Wire them into the formatters, display-only (single active currency, no conversion): - money.ts: activeCurrency/activeLocale read synchronously from localStorage at load (correct first paint), setMoneyFormat() write-through; formatUSD/ formatUSDWhole/formatCentsUSD honor them. Default USD/en-US is byte-identical, so money.test + the probe stay green. - utils.ts: fmtDate honors an activeDateFormat (MM/DD/YYYY | DD/MM/YYYY | YYYY-MM-DD) with setDateFormat() write-through. - FormatSync (mounted in Layout) reconciles the server settings → localStorage/ module and re-renders once only on a genuine cross-device divergence. - Settings selects write through on change, currency list expanded (AUD/CHF/JPY/ INR), "Regional" section, tooltips clarifying display-only. Co-Authored-By: Claude Opus 4.8 --- client/components/FormatSync.tsx | 37 +++++++++++++++++++++++ client/components/layout/Layout.tsx | 5 ++- client/lib/money.ts | 47 ++++++++++++++++++++++++++--- client/lib/utils.ts | 36 +++++++++++++++++++++- client/pages/SettingsPage.tsx | 18 ++++++++--- 5 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 client/components/FormatSync.tsx 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); }}>