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:
parent
e2d4a9f703
commit
0fa80a77ef
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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}
|
||||
>
|
||||
<PageTransition routeKey={location.pathname}>
|
||||
<Outlet />
|
||||
<FormatSync>
|
||||
<Outlet />
|
||||
</FormatSync>
|
||||
</PageTransition>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<LoginModeRecoverySection />
|
||||
|
||||
{/* General */}
|
||||
<SectionCard title="General">
|
||||
<SettingRow label="Currency" description="Default currency for bill amounts.">
|
||||
<Select value={settings.currency} onValueChange={(v) => set('currency', v)}>
|
||||
<SectionCard title="Regional">
|
||||
<SettingRow
|
||||
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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
@ -452,12 +456,16 @@ export default function SettingsPage() {
|
|||
<SelectItem value="EUR">EUR — Euro</SelectItem>
|
||||
<SelectItem value="GBP">GBP — British Pound</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>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
|
||||
<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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
|
|||
Loading…
Reference in New Issue