feat(settings): make Currency + Date-format real (plan Tier 2)

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 15:54:19 -05:00
parent e2d4a9f703
commit 0fa80a77ef
5 changed files with 131 additions and 12 deletions

View File

@ -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<string, unknown>;
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 <div key={version} className="contents">{children}</div>;
}

View File

@ -3,6 +3,7 @@ import { Link, Outlet, useLocation } from 'react-router-dom';
import AppNavigation from './Sidebar'; import AppNavigation from './Sidebar';
import { api } from '@/api'; import { api } from '@/api';
import PageTransition from '@/components/PageTransition'; import PageTransition from '@/components/PageTransition';
import { FormatSync } from '@/components/FormatSync';
function SimplefinBadge() { function SimplefinBadge() {
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
@ -38,7 +39,9 @@ export default function Layout({ mainContentId }: { mainContentId?: string }) {
id={mainContentId} id={mainContentId}
> >
<PageTransition routeKey={location.pathname}> <PageTransition routeKey={location.pathname}>
<Outlet /> <FormatSync>
<Outlet />
</FormatSync>
</PageTransition> </PageTransition>
</div> </div>
</main> </main>

View File

@ -35,6 +35,43 @@ type CentsInput = Cents | string | null | undefined;
const DASH = '—'; 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<string, string> = {
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 { function toNumber(value: unknown): number {
const n = Number(value); const n = Number(value);
if (!Number.isFinite(n)) return 0; if (!Number.isFinite(n)) return 0;
@ -54,9 +91,9 @@ export function formatUSD(
{ whole = false, dash = false }: { whole?: boolean; dash?: boolean } = {}, { whole = false, dash = false }: { whole?: boolean; dash?: boolean } = {},
): string { ): string {
if (dash && isBlank(dollars)) return DASH; if (dash && isBlank(dollars)) return DASH;
return toNumber(dollars).toLocaleString('en-US', { return toNumber(dollars).toLocaleString(activeLocale, {
style: 'currency', style: 'currency',
currency: 'USD', currency: activeCurrency,
minimumFractionDigits: whole ? 0 : 2, minimumFractionDigits: whole ? 0 : 2,
maximumFractionDigits: whole ? 0 : 2, maximumFractionDigits: whole ? 0 : 2,
}); });
@ -77,13 +114,13 @@ export function formatUSDWhole(
*/ */
export function formatCentsUSD( export function formatCentsUSD(
cents: CentsInput, 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 { ): string {
if (dash && isBlank(cents)) return DASH; if (dash && isBlank(cents)) return DASH;
const c = toNumber(cents); const c = toNumber(cents);
const body = (Math.abs(c) / 100).toLocaleString('en-US', { const body = (Math.abs(c) / 100).toLocaleString(activeLocale, {
style: 'currency', style: 'currency',
currency: currency || 'USD', currency: currency || activeCurrency,
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
}); });

View File

@ -20,10 +20,44 @@ export function errMessage(err: unknown, fallback = 'Something went wrong'): str
return err instanceof Error && err.message ? err.message : fallback; 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 { export function fmtDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—'; if (!dateStr) return '—';
const [y = '', m = '', d = ''] = dateStr.split('-'); 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 { export function localDateString(date: Date = new Date()): string {

View File

@ -4,7 +4,8 @@ import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { AlertCircle, Moon, Monitor, RefreshCw, Sun, Users, HelpCircle } from 'lucide-react'; import { AlertCircle, Moon, Monitor, RefreshCw, Sun, Users, HelpCircle } from 'lucide-react';
import { api } from '@/api'; 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { CalendarFeedManager } from '@/components/CalendarFeedManager'; import { CalendarFeedManager } from '@/components/CalendarFeedManager';
@ -441,9 +442,12 @@ export default function SettingsPage() {
<LoginModeRecoverySection /> <LoginModeRecoverySection />
{/* General */} {/* General */}
<SectionCard title="General"> <SectionCard title="Regional">
<SettingRow label="Currency" description="Default currency for bill amounts."> <SettingRow
<Select value={settings.currency} onValueChange={(v) => set('currency', v)}> label={<span className="inline-flex items-center">Currency<SettingHelp label="Currency" text="Changes how amounts are displayed (symbol and number format). Display only — it doesn't convert values; you still track one currency." /></span>}
description="How money amounts are shown across the app."
>
<Select value={settings.currency} onValueChange={(v) => { setMoneyFormat(v); set('currency', v); }}>
<SelectTrigger className="w-48"> <SelectTrigger className="w-48">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@ -452,12 +456,16 @@ export default function SettingsPage() {
<SelectItem value="EUR">EUR Euro</SelectItem> <SelectItem value="EUR">EUR Euro</SelectItem>
<SelectItem value="GBP">GBP British Pound</SelectItem> <SelectItem value="GBP">GBP British Pound</SelectItem>
<SelectItem value="CAD">CAD Canadian Dollar</SelectItem> <SelectItem value="CAD">CAD Canadian Dollar</SelectItem>
<SelectItem value="AUD">AUD Australian Dollar</SelectItem>
<SelectItem value="CHF">CHF Swiss Franc</SelectItem>
<SelectItem value="JPY">JPY Japanese Yen</SelectItem>
<SelectItem value="INR">INR Indian Rupee</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</SettingRow> </SettingRow>
<SettingRow label="Date format" description="How dates are displayed throughout the app."> <SettingRow label="Date format" description="How dates are displayed throughout the app.">
<Select value={settings.date_format} onValueChange={(v) => set('date_format', v)}> <Select value={settings.date_format} onValueChange={(v) => { setDateFormat(v); set('date_format', v); }}>
<SelectTrigger className="w-48"> <SelectTrigger className="w-48">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>