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 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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue