Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions LifeOS/install/LIFEOS/PULSE/Observability/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]) {
Expand Down Expand Up @@ -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<string, number> = { 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ interface FinancesDataV2 {
jsonl_path: string;
};
insights?: SpendInsights;
currency?: string;
// v1 legacy fields (still populated)
accounts?: Section[];
goals?: Section[];
Expand Down Expand Up @@ -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<string, string> = { 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 {
Expand Down Expand Up @@ -530,7 +561,7 @@ function TrendChart({ trend }: { trend: TrendPoint[] }) {
<YAxis
stroke="var(--ink-3)"
fontSize={11}
tickFormatter={(v) => `$${Math.round(v / 1000)}K`}
tickFormatter={(v) => `${currencySymbol}${Math.round(v / 1000)}K`}
/>
<Tooltip
contentStyle={{
Expand Down Expand Up @@ -1403,7 +1434,10 @@ export default function FinancesPage() {
useEffect(() => {
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)));
}, []);

Expand Down
5 changes: 5 additions & 0 deletions LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions LifeOS/install/USER/CONFIG/LIFEOS_CONFIG.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down