From e3715053231f3f53238cfc3abd4f10be1807edec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 04:40:02 +0000 Subject: [PATCH] Add bidirectional FX rate entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now enter the exchange rate from either direction: - "35 THB per USD" (foreign per base, the canonical form) - "0.02857 USD per THB" (base per foreign, inverted display) A compact inline select next to the rate field lets the user choose which currency is in the numerator; the "per X" label updates to show the denominator. Switching direction re-displays the current rate inverted — the stored canonical (foreign/base) is unchanged, so recalc(), fxRateMemory, and PDF output are unaffected. Persists fxDir alongside fxRate in localStorage/IndexedDB. https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ --- app/index.html | 74 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/app/index.html b/app/index.html index 2ff589f..ea2000c 100644 --- a/app/index.html +++ b/app/index.html @@ -521,7 +521,7 @@ function newItem() { return { id: uid(), name: '', lines: [newLine()], _subtotal function newLine() { return { id: uid(), date: state.periodFrom || '', description: '', currency: state.baseCurrency, - fxRate: '1.00000', vendor: '', hasReceipt: true, receipts: [], + fxRate: '1.00000', fxDir: 'foreign', vendor: '', hasReceipt: true, receipts: [], noReceiptExplanation: '', amount: '', account: '', customCurrency: false, programs: [{ program: '', percent: '', programOther: '' }] }; @@ -563,7 +563,7 @@ async function saveState() { id: item.id, name: item.name, lines: item.lines.map(ln => ({ id: ln.id, date: ln.date, description: ln.description, - currency: ln.currency, fxRate: ln.fxRate, vendor: ln.vendor, + currency: ln.currency, fxRate: ln.fxRate, fxDir: ln.fxDir || 'foreign', vendor: ln.vendor, hasReceipt: ln.hasReceipt, noReceiptExplanation: ln.noReceiptExplanation, amount: ln.amount, account: ln.account, customCurrency: ln.customCurrency || false, programs: ln.programs, @@ -599,7 +599,7 @@ async function loadState() { for (const ld of (id.lines || [])) { const ln = { id: ld.id, date: ld.date, description: ld.description, - currency: ld.currency, fxRate: ld.fxRate, vendor: ld.vendor, + currency: ld.currency, fxRate: ld.fxRate, fxDir: ld.fxDir || 'foreign', vendor: ld.vendor, hasReceipt: ld.hasReceipt, receipts: [], noReceiptExplanation: ld.noReceiptExplanation, amount: ld.amount, account: ld.account, @@ -933,19 +933,60 @@ function renderLine(ln, item) { const vendIn = el('input', {className:'kb-input', type:'text', value: ln.vendor, placeholder:'Vendor name'}); vendIn.addEventListener('input', () => { ln.vendor = vendIn.value; }); - // FX - const fxIn = el('input', {className:'kb-input num', type:'text', placeholder:'0.00000', style:{minWidth:'0'}}); - fxIn.value = ln.fxRate; + // FX — bidirectional rate entry + // Canonical: ln.fxRate is always "foreign per 1 base" (divide to get base amount). + // ln.fxDir controls display direction: 'foreign' = show canonical, 'base' = show 1/canonical. + const fxIn = el('input', {className:'kb-input num', type:'text', placeholder:'0.00000', style:{minWidth:'0', width:'90px'}}); + const fxDirSel = el('select', {className:'kb-select', style:{padding:'0 20px 0 6px', fontSize:'var(--fs-small)', minWidth:'0', width:'auto'}}); + const fxPerLbl = el('span', {style:{fontSize:'var(--fs-small)', color:'var(--text-muted)', whiteSpace:'nowrap', userSelect:'none'}}); + const fxWrap = el('div', {style:{display:'flex', alignItems:'center', gap:'4px'}}); + fxWrap.append(fxIn, fxDirSel, fxPerLbl); + + function fxDisplayVal() { + if (!ln.fxRate || ln.fxRate === '' || ln.fxRate === '1.00000') return ln.fxRate; + const r = parseFloat(ln.fxRate); + if (ln.fxDir === 'base' && r > 0) return (1 / r).toFixed(5); + return ln.fxRate; + } + function fxStoreFromDisplay(displayVal) { + const v = parseFloat(displayVal); + if (isNaN(v) || v <= 0) { ln.fxRate = displayVal; return; } + ln.fxRate = ln.fxDir === 'base' ? (1 / v).toFixed(5) : displayVal; + } + function rebuildFxDirSel() { + const fcy = ln.currency || '???'; + fxDirSel.innerHTML = ''; + fxDirSel.append( + el('option', {value:'foreign'}, fcy), + el('option', {value:'base'}, baseCur) + ); + fxDirSel.value = ln.fxDir || 'foreign'; + fxPerLbl.textContent = 'per ' + (fxDirSel.value === 'foreign' ? baseCur : fcy); + } + function showFxControls(show) { + fxDirSel.style.display = show ? '' : 'none'; + fxPerLbl.style.display = show ? '' : 'none'; + } + + fxIn.value = fxDisplayVal(); fxIn.readOnly = !ln.customCurrency && ln.currency === baseCur; - if (fxIn.readOnly) fxIn.className = 'kb-input num'; + rebuildFxDirSel(); + showFxControls(ln.currency !== baseCur); + fxIn.addEventListener('input', () => { - ln.fxRate = fxIn.value; - const rate = parseFloat(fxIn.value); - if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = fxIn.value; + fxStoreFromDisplay(fxIn.value); + const rate = parseFloat(ln.fxRate); + if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = ln.fxRate; recalc(); if (progAreaEl) progAreaEl._refresh(); }); + fxDirSel.addEventListener('change', () => { + ln.fxDir = fxDirSel.value; + fxIn.value = fxDisplayVal(); + fxPerLbl.textContent = 'per ' + (ln.fxDir === 'foreign' ? baseCur : ln.currency || '???'); + }); + async function showFxModal(code) { const bn = getCurrencyName(baseCur); const isOther = !code || code === 'Other'; @@ -958,12 +999,17 @@ function renderLine(ln, item) { function applyFxCurrency(code) { if (code === baseCur) { - ln.fxRate = '1.00000'; fxIn.value = '1.00000'; fxIn.readOnly = true; + ln.fxRate = '1.00000'; ln.fxDir = 'foreign'; + fxIn.value = '1.00000'; fxIn.readOnly = true; + showFxControls(false); } else { fxIn.readOnly = false; const mem = state.fxRateMemory[code]; - if (mem) { ln.fxRate = mem; fxIn.value = mem; } - else if (ln.fxRate === '1.00000') { ln.fxRate = ''; fxIn.value = ''; } + if (mem) { ln.fxRate = mem; } + else if (ln.fxRate === '1.00000') { ln.fxRate = ''; } + rebuildFxDirSel(); + fxIn.value = fxDisplayVal(); + showFxControls(true); } recalc(); if (progAreaEl) progAreaEl._refresh(); @@ -1019,7 +1065,7 @@ function renderLine(ln, item) { kbField('Date', dateIn), kbField('Vendor', vendIn), kbField('Currency', curWrap), - kbField('FX rate', fxIn) + kbField('FX rate', fxWrap) ); blk.appendChild(row1);