diff --git a/LifeOS/install/LIFEOS/PULSE/Observability/observability.ts b/LifeOS/install/LIFEOS/PULSE/Observability/observability.ts index b0fa18ee71..9b0f7a7a41 100644 --- a/LifeOS/install/LIFEOS/PULSE/Observability/observability.ts +++ b/LifeOS/install/LIFEOS/PULSE/Observability/observability.ts @@ -33,6 +33,7 @@ import { join, extname } from "path" import { readFileSync, readdirSync, existsSync, realpathSync, statSync, watch, type FSWatcher } from "fs" import YAML from "yaml" import { effortToCanonicalTierName } from "../../../hooks/lib/effort" +import { loadLifeosConfig } from "../../TOOLS/LifeosConfig" // Normalize env path vars that Claude Code injects without shell expansion (LifeOS#1404) for (const k of ["LIFEOS_DIR", "LIFEOS_CONFIG_DIR", "PROJECTS_DIR"]) { @@ -1533,13 +1534,27 @@ function parseCurrencyTable(content: string): { label: string; annual: number }[ return rows } +// Magnitude suffixes recognized after a numeral, keyed case-insensitively. +// Data, not branching — a new locale's short-scale word is a new entry here, +// not a new code path. "K"/"M" cover English "$12K"; "万"/"億" cover Japanese +// "¥120万" (man, 10,000) / "1.2億" (oku, 100,000,000). +const CURRENCY_MAGNITUDE: Record = { k: 1_000, m: 1_000_000, "万": 10_000, "億": 100_000_000 } + function parseCurrencyCell(cell: string): number { if (!cell) return 0 - const cleaned = cell.replace(/\*\*/g, "").replace(/[~$,]/g, "").trim() - const km = cleaned.match(/^([\d.]+)\s*([KkMm])\b/) - if (km) { - const base = parseFloat(km[1]) - return km[2].toLowerCase() === "m" ? base * 1_000_000 : base * 1_000 + // Strip bold markers, common currency symbols (¥ ¥ $ € £ ₩ ₹), the word + // "円" (yen), approximation markers ("~", "約"), and thousands commas + // before magnitude parsing. + const cleaned = cell.replace(/\*\*/g, "").replace(/[~$,¥¥€£₩₹]|円|約/g, "").trim() + for (const [suffix, mult] of Object.entries(CURRENCY_MAGNITUDE)) { + // \b only makes sense for ASCII letter suffixes (K/M) — it's what stopped + // the original regex from matching into a following word. \b is unreliable + // for CJK suffixes (万/億 aren't \w chars), so skip it there; the fixed + // single-character alternation is unambiguous without it. + const boundary = /^[a-z]$/i.test(suffix) ? "\\b" : "" + const re = new RegExp(`^([\\d.]+)\\s*(${suffix})${boundary}`, "i") + const m = cleaned.match(re) + if (m) return parseFloat(m[1]) * mult } const plain = cleaned.match(/^[\d.]+/) return plain ? parseFloat(plain[0]) : 0 @@ -1738,6 +1753,18 @@ function handleLifeHealth(): Response { // ── GET /api/life/finances ── +// ISO 4217 code driving the Finances tab's number formatting. Reads +// [principal].currency from LIFEOS_CONFIG.toml; missing config, missing key, +// or a malformed TOML all fall back to "USD" rather than failing the request +// (same fail-open contract as loadYaml() above). +function resolveFinanceCurrency(): string { + try { + return loadLifeosConfig().principal.currency || "USD" + } catch { + return "USD" + } +} + // PLAN.md parsers. The forward-plan file is human-authored markdown; these // turn its `## Flywheel` ordered list into stages and any pipe-table (e.g. // `## Targets`) into headers+rows. All plan CONTENT lives in PLAN.md — these @@ -1875,7 +1902,7 @@ function handleLifeFinances(): Response { const name = o.name ?? o.vendor ?? o.id const amount = typeof o.amount_usd === "number" ? o.amount_usd - : Number(String(o.amount ?? "").replace(/[^0-9.]/g, "")) || 0 + : parseCurrencyCell(String(o.amount ?? "")) return { ...o, id: typeof o.id === "string" ? o.id : slugify(name), @@ -2063,6 +2090,7 @@ function handleLifeFinances(): Response { return Response.json({ // v2 envelope ...v2, + currency: resolveFinanceCurrency(), // v1 fields preserved (backward compat for existing page.tsx until migrated) accounts: parseSections(readMd(join(FINANCES_DIR, "ACCOUNTS.md"))), expenses: parseSections(expensesRaw), diff --git a/LifeOS/install/LIFEOS/PULSE/Observability/src/app/finances/page.tsx b/LifeOS/install/LIFEOS/PULSE/Observability/src/app/finances/page.tsx index 3f70e0f8e2..429f4c95ce 100644 --- a/LifeOS/install/LIFEOS/PULSE/Observability/src/app/finances/page.tsx +++ b/LifeOS/install/LIFEOS/PULSE/Observability/src/app/finances/page.tsx @@ -149,6 +149,7 @@ interface FinancesDataV2 { jsonl_path: string; }; insights?: SpendInsights; + currency?: string; // v1 legacy fields (still populated) accounts?: Section[]; goals?: Section[]; @@ -183,20 +184,50 @@ interface FinancesDataV2 { // ─── Formatting ─── +// Deterministic symbols for the currencies LifeOS ships sample data for — +// checked first so the default ("USD" when [principal].currency is unset) +// resolves to the exact literal this page always used, independent of the +// server's ICU/locale environment. Any other ISO 4217 code falls through to +// Intl as a best-effort lookup. +const CURRENCY_SYMBOL_FALLBACK: Record = { USD: "$", JPY: "¥", EUR: "€", GBP: "£" }; + +function resolveCurrencySymbol(currency: string): string { + const known = CURRENCY_SYMBOL_FALLBACK[currency]; + if (known) return known; + try { + const parts = new Intl.NumberFormat(undefined, { style: "currency", currency }).formatToParts(0); + return parts.find((p) => p.type === "currency")?.value ?? currency; + } catch { + return currency; + } +} + +// Module-level rather than component state: fmtHero is also called from +// SankeyNode, a plain function recharts invokes directly (not a component in +// the tree), so it has no access to hooks or props threaded from FinancesPage. +// Defaults to "$" — the literal every version of this page has rendered — so +// an install with no [principal].currency configured, or a request that +// hasn't resolved yet, is byte-identical to before currency support existed. +let currencySymbol = "$"; + +function setCurrency(currency: string | undefined | null): void { + currencySymbol = resolveCurrencySymbol(currency || "USD"); +} + function fmtHero(dollars: number | null | undefined): string { const n = Number(dollars) || 0; - if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`; - if (n >= 10_000) return `$${Math.round(n / 1000)}K`; + if (n >= 1_000_000) return `${currencySymbol}${(n / 1_000_000).toFixed(1)}M`; + if (n >= 10_000) return `${currencySymbol}${Math.round(n / 1000)}K`; if (n >= 1_000) { const k = n / 1000; - return k % 1 === 0 ? `$${k.toFixed(0)}K` : `$${k.toFixed(1)}K`; + return k % 1 === 0 ? `${currencySymbol}${k.toFixed(0)}K` : `${currencySymbol}${k.toFixed(1)}K`; } - return `$${Math.round(n).toLocaleString()}`; + return `${currencySymbol}${Math.round(n).toLocaleString()}`; } function fmtExact(dollars: number | null | undefined): string { const n = Number(dollars) || 0; - return `$${n.toLocaleString("en-US", { maximumFractionDigits: 0 })}`; + return `${currencySymbol}${n.toLocaleString("en-US", { maximumFractionDigits: 0 })}`; } function fmtPct(rate: number | null | undefined): string { @@ -530,7 +561,7 @@ function TrendChart({ trend }: { trend: TrendPoint[] }) { `$${Math.round(v / 1000)}K`} + tickFormatter={(v) => `${currencySymbol}${Math.round(v / 1000)}K`} /> { fetch("/api/life/finances") .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) - .then(setData) + .then((json: FinancesDataV2) => { + setCurrency(json.currency); + setData(json); + }) .catch((e) => setError(String(e))); }, []); diff --git a/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts b/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts index ca5d03d9b9..5d9df522e0 100644 --- a/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts +++ b/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts @@ -38,6 +38,10 @@ export interface LifeosPrincipal { timezone: string; hometown?: string; voiceCloneId?: string; + // ISO 4217 code (e.g. "JPY", "EUR"). Optional — consumers (currently just + // Pulse's Finances tab) default to "USD" when unset, so existing TOML files + // need no edit. + currency?: string; } export interface LifeosVoiceSettings { @@ -169,6 +173,7 @@ function validateAndNormalize(raw: unknown, path: string): LifeosConfig { timezone: principal.timezone, hometown: principal.hometown, voiceCloneId: principal.voice_clone_id ?? principal.voiceCloneId, + currency: principal.currency, }, da: { name: da.name, diff --git a/LifeOS/install/USER/CONFIG/LIFEOS_CONFIG.toml b/LifeOS/install/USER/CONFIG/LIFEOS_CONFIG.toml index 7a9d2b20c9..9d96188e4b 100644 --- a/LifeOS/install/USER/CONFIG/LIFEOS_CONFIG.toml +++ b/LifeOS/install/USER/CONFIG/LIFEOS_CONFIG.toml @@ -15,6 +15,7 @@ name = "Your Name" # required — your display name pronunciation = "" # optional — phonetic spelling if needed timezone = "America/Los_Angeles" # required — IANA timezone hometown = "" # optional +currency = "USD" # optional — ISO 4217, drives Pulse Finances tab formatting [da] name = "Aria" # required — your DA's name (rename to anything)