From a8ec2a3239a601d9ae4f41e3e54474a655abd49e Mon Sep 17 00:00:00 2001 From: Potter Rafed Date: Sun, 9 Aug 2026 18:42:44 +0300 Subject: [PATCH 1/2] fix(pulse/finances): non-USD amounts parse as zero; outbound scope and sample-vendor matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in the Finances data layer, each of which produces a plausible-looking but wrong dashboard rather than a visible failure. 1. parseCurrencyCell stripped $ and commas but not £ or €, so a cell like "£1,234" survived cleaning as "£1234", failed the leading-digit match and returned 0. Every row on a non-USD dashboard silently became zero. Also accepts the Unicode minus (U+2212) so negative rows parse rather than being dropped by the amount > 0 filter. USD behaviour is unchanged. 2. Obligation scope was hardcoded "personal" in two places — once in the YAML normalizer and again in the resolver — so an obligations.yaml declaring scope: business was ignored, and fixing only the first has no visible effect. 3. knownLabels included vendors and obligations resolving to zero. Every row of the shipped sample vendors.yaml is zero-value and "unconfigured", and the substring matcher let those sample names suppress real EXPENSES.md rows. A user who fills in EXPENSES.md without clearing the sample file loses those expenses from the outbound total with no warning. Only entries that actually contribute spend can now suppress a row, which is the only case where suppression prevents genuine double-counting. --- .../PULSE/Observability/observability.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/LifeOS/install/LIFEOS/PULSE/Observability/observability.ts b/LifeOS/install/LIFEOS/PULSE/Observability/observability.ts index a7cf16dba4..7c6b5f9aa5 100644 --- a/LifeOS/install/LIFEOS/PULSE/Observability/observability.ts +++ b/LifeOS/install/LIFEOS/PULSE/Observability/observability.ts @@ -2120,13 +2120,18 @@ function parseCurrencyTable(content: string): { label: string; annual: number }[ 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/) + // Strip £ and € alongside $. A GBP cell such as "£1,234" previously survived + // cleaning as "£1234", failed the leading-digit match and returned 0, so every + // row on a non-USD dashboard silently became zero rather than failing visibly. + // Also accept the Unicode minus (U+2212) so negative rows parse instead of + // being dropped by the `amount > 0` filter in parseCurrencyTable. + const cleaned = cell.replace(/\*\*/g, "").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 } - const plain = cleaned.match(/^[\d.]+/) + const plain = cleaned.match(/^-?[\d.]+/) return plain ? parseFloat(plain[0]) : 0 } @@ -2478,7 +2483,9 @@ function handleLifeFinances(): Response { ...o, id: typeof o.id === "string" ? o.id : slugify(name), name, - scope: "personal", + // Honour an explicit scope when the file states one. Hardcoding this + // mislabelled every business-paid obligation as personal. + scope: typeof (o as any).scope === "string" ? (o as any).scope : "personal", cadence: o.cadence ?? FREQUENCY_TO_CADENCE[o.frequency] ?? "variable", amount_usd: amount, category: o.category ?? "other", @@ -2540,7 +2547,9 @@ function handleLifeFinances(): Response { return { id: o.id, name: o.name ?? o.id, - scope: "personal", + // Second place the same field was flattened; fixing only the normalizer + // above has no visible effect because this override runs after it. + scope: (o as any).scope ?? "personal", monthly_usd: Math.round(monthly * 100) / 100, annual_usd: Math.round(monthly * 12 * 100) / 100, source: "manual", @@ -2555,10 +2564,15 @@ function handleLifeFinances(): Response { // Matching is case-insensitive substring in either direction. // Guarded + empty-filtered: a missing id/name must neither throw nor add // "" to the set (an empty known label substring-matches every expense row). + // Only suppress an EXPENSES.md row when the matching vendor/obligation actually + // contributes spend. A £0 "unconfigured" entry — every row of the shipped sample + // vendors.yaml, until a user clears it — has nothing to double-count, so letting + // it match silently deleted real expenses from the outbound total. + const contributes = (l: ResolvedLine) => l.monthly_usd > 0 || l.annual_usd > 0 const knownLabels = new Set([ - ...resolvedVendors.map(v => (v.name ?? v.id ?? "").toLowerCase()), - ...resolvedVendors.map(v => (v.id ?? v.name ?? "").toLowerCase()), - ...resolvedObligations.map(o => (o.name ?? o.id ?? "").toLowerCase()), + ...resolvedVendors.filter(contributes).map(v => (v.name ?? v.id ?? "").toLowerCase()), + ...resolvedVendors.filter(contributes).map(v => (v.id ?? v.name ?? "").toLowerCase()), + ...resolvedObligations.filter(contributes).map(o => (o.name ?? o.id ?? "").toLowerCase()), ].filter(Boolean)) const otherOutbound: ResolvedLine[] = expenseCategories .filter(e => { From 052e38e246b61f7e2777ce8727084d4fb89473a8 Mon Sep 17 00:00:00 2001 From: Potter Rafed Date: Sun, 9 Aug 2026 18:42:44 +0300 Subject: [PATCH 2/2] feat(pulse/finances): render the currency from state.json instead of a hard-coded $ The dashboard formatted every figure with a literal $. Read state.currency from the API payload and pick the symbol from it, falling back to $ when the field is absent so existing installs are unaffected. Pairs with the parseCurrencyCell fix: without both, a GBP install shows $0 everywhere. --- .../Observability/src/app/finances/page.tsx | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) 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 13ef9a0527..fa0a4e4f84 100644 --- a/LifeOS/install/LIFEOS/PULSE/Observability/src/app/finances/page.tsx +++ b/LifeOS/install/LIFEOS/PULSE/Observability/src/app/finances/page.tsx @@ -162,6 +162,7 @@ interface FinancesDataV2 { targets: { headers: string[]; rows: string[][] } | null; sections: Section[]; }; + state?: { currency?: string; [k: string]: unknown }; incomeStreams?: Stream[]; expenseCategories?: Stream[]; annualIncome?: number; @@ -183,20 +184,31 @@ interface FinancesDataV2 { // ─── Formatting ─── -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`; +// Currency symbol, driven by state.json's `currency` field via the API payload. +// Defaults to "$" so an install that never sets it behaves exactly as before. +const CURRENCY_SYMBOLS: Record = { USD: "$", GBP: "£", EUR: "€" }; +let CURRENCY = "$"; +let CURRENCY_LOCALE = "en-US"; +export function setCurrency(code: string | null | undefined) { + if (!code) return; + CURRENCY = CURRENCY_SYMBOLS[code.toUpperCase()] ?? "$"; + CURRENCY_LOCALE = code.toUpperCase() === "GBP" ? "en-GB" : "en-US"; +} + +function fmtHero(amount: number | null | undefined): string { + const n = Number(amount) || 0; + if (n >= 1_000_000) return `${CURRENCY}${(n / 1_000_000).toFixed(1)}M`; + if (n >= 10_000) return `${CURRENCY}${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 ? `${CURRENCY}${k.toFixed(0)}K` : `${CURRENCY}${k.toFixed(1)}K`; } - return `$${Math.round(n).toLocaleString()}`; + return `${CURRENCY}${Math.round(n).toLocaleString()}`; } -function fmtExact(dollars: number | null | undefined): string { - const n = Number(dollars) || 0; - return `$${n.toLocaleString("en-US", { maximumFractionDigits: 0 })}`; +function fmtExact(amount: number | null | undefined): string { + const n = Number(amount) || 0; + return `${CURRENCY}${n.toLocaleString(CURRENCY_LOCALE, { maximumFractionDigits: 0 })}`; } function fmtPct(rate: number | null | undefined): string { @@ -530,7 +542,7 @@ function TrendChart({ trend }: { trend: TrendPoint[] }) { `$${Math.round(v / 1000)}K`} + tickFormatter={(v) => `${CURRENCY}${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((d: FinancesDataV2) => { + setCurrency(d?.state?.currency); + setData(d); + }) .catch((e) => setError(String(e))); }, []);