From 43b5e2040f4938d839ca0a990195e08244b39f6c Mon Sep 17 00:00:00 2001 From: SarahDoma <108196951+SarahDoma@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:02:22 +0100 Subject: [PATCH] fix: investment form leading zeros, nav prop, decimal rounding, add return projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip leading zeros in AmountInput so "007" becomes "7" instead of displaying an unprofessional zero-padded number (#370). - Simplify Landing's onNav(screen) to onExplore(), since it was only ever called with 'explore' — the prop signature now matches its actual, single behaviour (#373). - Add roundToDecimals/roundToCents/formatDecimal helpers and round investment amounts once to cents, reusing that value everywhere it's displayed, so Deposit and Withdraw no longer show inconsistent rounded figures like 2.00 next to an underlying 1.995 (#369). - Show a real-time projected 1-year return next to the deposit amount input, computed from the pool's bond yield via a new projectedReturn helper (#367). Closes #370 Closes #373 Closes #369 Closes #367 --- messages/en.json | 1 + messages/fr.json | 1 + src/app/page.tsx | 4 +- src/app/verify/page.tsx | 2 +- src/components/AmountInput.test.tsx | 24 ++++++ src/components/AmountInput.tsx | 20 ++++- src/lib/bondUtils.ts | 94 ++++++++++++----------- src/lib/format.ts | 25 ++++++ src/screens/Deposit.tsx | 115 ++++++++++++++++++++++++---- src/screens/Landing.tsx | 7 +- src/screens/Withdraw.tsx | 8 +- 11 files changed, 230 insertions(+), 71 deletions(-) diff --git a/messages/en.json b/messages/en.json index b10c16cd..97050d81 100644 --- a/messages/en.json +++ b/messages/en.json @@ -117,6 +117,7 @@ "balanceLabel": "Balance", "amountH1": "How much would you like to invest?", "preview": "You'll receive ≈ {shares} HBS · share price {price} · network fee < $0.01", + "projection": "Projected return in 1 year: ≈ ${amount} USDC at {rate}% APY", "liquidLine": "Today the pool is 29% liquid — you can withdraw your liquid share anytime.", "investCta": "Invest {amount} USDC", "investCtaEmpty": "Invest", diff --git a/messages/fr.json b/messages/fr.json index d12ea95c..706147cc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -117,6 +117,7 @@ "balanceLabel": "Solde", "amountH1": "Combien souhaitez-vous investir ?", "preview": "Vous recevrez ≈ {shares} HBS · prix de la part {price} · frais réseau < $0.01", + "projection": "Rendement projeté sur 1 an : ≈ {amount} $ USDC à {rate} % de rendement annuel", "liquidLine": "Aujourd'hui le pool est liquide à 29 % — vous pouvez retirer votre part liquide à tout moment.", "investCta": "Investir {amount} USDC", "investCtaEmpty": "Investir", diff --git a/src/app/page.tsx b/src/app/page.tsx index a47f2ae3..2ad7c9eb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,5 +5,7 @@ import { Landing } from '../screens/Landing' export default function HomePage() { const router = useRouter() - return router.push('/connect')} onNav={() => router.push('/explore')} /> + return ( + router.push('/connect')} onExplore={() => router.push('/explore')} /> + ) } diff --git a/src/app/verify/page.tsx b/src/app/verify/page.tsx index 82095093..1c89cbc2 100644 --- a/src/app/verify/page.tsx +++ b/src/app/verify/page.tsx @@ -7,7 +7,7 @@ export default function VerifyPage() { const t = useTranslations('Footer') return (
- {}} onNav={() => {}} /> + {}} onExplore={() => {}} />

{ expect(onChangeValue).toBe('0.001') }) + it('strips leading zeros from the whole-number part (#370)', () => { + let onChangeValue = '' + const onChange = (value: string) => { + onChangeValue = value + } + const { container } = render() + const input = container.querySelector('input') as HTMLInputElement + + fireEvent.change(input, { target: { value: '007' } }) + expect(onChangeValue).toBe('7') + + fireEvent.change(input, { target: { value: '00.5' } }) + expect(onChangeValue).toBe('0.5') + + fireEvent.change(input, { target: { value: '0' } }) + expect(onChangeValue).toBe('0') + + fireEvent.change(input, { target: { value: '0.001' } }) + expect(onChangeValue).toBe('0.001') + + fireEvent.change(input, { target: { value: '010.25' } }) + expect(onChangeValue).toBe('10.25') + }) + it('rejects multiple decimal points', () => { let result = '' const onChange = (value: string) => { diff --git a/src/components/AmountInput.tsx b/src/components/AmountInput.tsx index b2d44bb4..329be0ff 100644 --- a/src/components/AmountInput.tsx +++ b/src/components/AmountInput.tsx @@ -109,9 +109,9 @@ export function AmountInput({ value={value} onFocus={(e) => e.target.select()} onChange={(e) => { - const v = sanitizeAmount(e.target.value); + const v = sanitizeAmount(e.target.value) // If editing existing value, typing should replace not append when field was pre-filled - onChange?.(v); + onChange?.(v) }} style={{ flex: 1, @@ -138,7 +138,14 @@ export function AmountInput({

-

+

Min 1 USDC — Max {cap ?? '—'} USDC

@@ -257,5 +264,10 @@ const chipStyle: CSSProperties = { export function sanitizeAmount(val: string): string { const clean = val.replace(/[^0-9.]/g, '') const parts = clean.split('.') - return parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : clean + const joined = parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : clean + const [whole, ...rest] = joined.split('.') + // Strip leading zeros from the whole-number part ("007" -> "7", "00.5" -> "0.5"), + // but keep a single zero so "0", "0.5" stay valid while typing. + const trimmedWhole = whole.replace(/^0+(?=\d)/, '') + return rest.length > 0 ? trimmedWhole + '.' + rest.join('.') : trimmedWhole } diff --git a/src/lib/bondUtils.ts b/src/lib/bondUtils.ts index a1ab5d93..42143533 100644 --- a/src/lib/bondUtils.ts +++ b/src/lib/bondUtils.ts @@ -4,84 +4,92 @@ * - #363 case-insensitive search * - #359 stable sort with tie-breaker * - #361 bond comparison view data helper + * - #367 projected return from an investment amount + annual yield */ export interface Bond { - id: string | number; - name: string; - yield: number; - term: number; - rating: string; + id: string | number + name: string + yield: number + term: number + rating: string } -const YIELD_FILTER_KEY = 'bond_yield_filter'; -const YIELD_DEFAULT: [number, number] = [0, 15]; +const YIELD_FILTER_KEY = 'bond_yield_filter' +const YIELD_DEFAULT: [number, number] = [0, 15] export function getPersistedYieldRange(): [number, number] { - if (typeof window === 'undefined') return YIELD_DEFAULT; + if (typeof window === 'undefined') return YIELD_DEFAULT try { - const url = new URL(window.location.href); - const fromUrl = url.searchParams.get('yieldRange'); + const url = new URL(window.location.href) + const fromUrl = url.searchParams.get('yieldRange') if (fromUrl) { - const [min, max] = fromUrl.split('-').map(Number); - if (Number.isFinite(min) && Number.isFinite(max)) return [min, max]; + const [min, max] = fromUrl.split('-').map(Number) + if (Number.isFinite(min) && Number.isFinite(max)) return [min, max] } - const stored = localStorage.getItem(YIELD_FILTER_KEY); + const stored = localStorage.getItem(YIELD_FILTER_KEY) if (stored) { - const parsed = JSON.parse(stored); - if (Array.isArray(parsed) && parsed.length === 2) return parsed as [number, number]; + const parsed = JSON.parse(stored) + if (Array.isArray(parsed) && parsed.length === 2) return parsed as [number, number] } } catch {} - return YIELD_DEFAULT; + return YIELD_DEFAULT } export function persistYieldRange(range: [number, number]): void { - if (typeof window === 'undefined') return; + if (typeof window === 'undefined') return try { - localStorage.setItem(YIELD_FILTER_KEY, JSON.stringify(range)); - const url = new URL(window.location.href); - url.searchParams.set('yieldRange', `${range[0]}-${range[1]}`); - window.history.replaceState(null, '', url.toString()); + localStorage.setItem(YIELD_FILTER_KEY, JSON.stringify(range)) + const url = new URL(window.location.href) + url.searchParams.set('yieldRange', `${range[0]}-${range[1]}`) + window.history.replaceState(null, '', url.toString()) } catch {} } export function filterBondsByYield(bonds: Bond[], range: [number, number]): Bond[] { - const [min, max] = range; - return bonds.filter((b) => b.yield >= min && b.yield <= max); + const [min, max] = range + return bonds.filter((b) => b.yield >= min && b.yield <= max) } // #363 — case-insensitive search export function searchBondsByName(bonds: Bond[], query: string): Bond[] { - const q = query.trim().toLowerCase(); - if (!q) return bonds; - return bonds.filter((b) => b.name.toLowerCase().includes(q)); + const q = query.trim().toLowerCase() + if (!q) return bonds + return bonds.filter((b) => b.name.toLowerCase().includes(q)) } // #359 — stable sort with tie-breaker (name, then id) export function sortBondsByYield(bonds: Bond[], direction: 'asc' | 'desc' = 'asc'): Bond[] { - const dir = direction === 'asc' ? 1 : -1; + const dir = direction === 'asc' ? 1 : -1 return [...bonds].sort((a, b) => { - if (a.yield !== b.yield) return (a.yield - b.yield) * dir; - const nameCmp = a.name.localeCompare(b.name); - if (nameCmp !== 0) return nameCmp; - return String(a.id).localeCompare(String(b.id)); - }); + if (a.yield !== b.yield) return (a.yield - b.yield) * dir + const nameCmp = a.name.localeCompare(b.name) + if (nameCmp !== 0) return nameCmp + return String(a.id).localeCompare(String(b.id)) + }) } // #361 — bond comparison (side-by-side) helper -export function getBondsForComparison(bonds: Bond[], ids: (string|number)[]): Bond[] { - if (ids.length < 2 || ids.length > 3) throw new Error('Select 2-3 bonds to compare'); - const map = new Map(bonds.map((b) => [String(b.id), b])); - const selected = ids.map((id) => map.get(String(id))).filter(Boolean) as Bond[]; - if (selected.length !== ids.length) throw new Error('One or more bonds not found'); - return selected; +export function getBondsForComparison(bonds: Bond[], ids: (string | number)[]): Bond[] { + if (ids.length < 2 || ids.length > 3) throw new Error('Select 2-3 bonds to compare') + const map = new Map(bonds.map((b) => [String(b.id), b])) + const selected = ids.map((id) => map.get(String(id))).filter(Boolean) as Bond[] + if (selected.length !== ids.length) throw new Error('One or more bonds not found') + return selected } -export function compareBondsMetrics(bonds: Bond[]): Record { - const metrics = ['yield', 'term', 'rating', 'name'] as const; - const result: Record = {}; +// #367 — projected return on an investment amount at a given annual yield (%), +// simple (non-compounding) interest over the given number of years. +export function projectedReturn(amount: number, annualYieldPct: number, years = 1): number { + if (!Number.isFinite(amount) || amount <= 0) return 0 + return amount * (annualYieldPct / 100) * years +} + +export function compareBondsMetrics(bonds: Bond[]): Record { + const metrics = ['yield', 'term', 'rating', 'name'] as const + const result: Record = {} for (const m of metrics) { - result[m] = bonds.map((b) => (b as any)[m]); + result[m] = bonds.map((b) => (b as any)[m]) } - return result; + return result } diff --git a/src/lib/format.ts b/src/lib/format.ts index d998466f..850f2b8e 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,3 +1,28 @@ +/** + * Rounds a number to a fixed number of decimals using decimal (not binary + * floating-point) precision, so 1.005 rounds to 1.01 rather than the 1.00 that + * `Math.round(1.005 * 100) / 100` or `(1.005).toFixed(2)` produce because + * 1.005 has no exact binary representation (#369). + */ +export function roundToDecimals(value: number, decimals: number): number { + const factor = Math.pow(10, decimals) + return Math.round((value + Number.EPSILON) * factor) / factor +} + +/** Rounds to whole cents — the shared precision for on-screen USDC amounts (#369). */ +export function roundToCents(value: number): number { + return roundToDecimals(value, 2) +} + +/** + * Formats a number to a fixed number of decimals, rounding once with + * {@link roundToDecimals} first so every caller displays the same rounded + * value instead of re-rounding raw floating-point results independently (#369). + */ +export function formatDecimal(value: number, decimals: number): string { + return roundToDecimals(value, decimals).toFixed(decimals) +} + /** * Formats a number as a localized currency/money string. * Defaults to 'en-US' formatting. diff --git a/src/screens/Deposit.tsx b/src/screens/Deposit.tsx index 06d382f4..bec687d7 100644 --- a/src/screens/Deposit.tsx +++ b/src/screens/Deposit.tsx @@ -10,6 +10,8 @@ import { scrollToFirstError } from '../lib/scrollToError' import { getFriendlyErrorMessage } from '../lib/errorMessages' import { useWallet } from '../wallet/WalletProvider' import { HB_DATA } from '../data' +import { roundToCents, formatDecimal } from '../lib/format' +import { projectedReturn } from '../lib/bondUtils' /** * Deposit — the flow that must be perfect. One column, one decision per step: @@ -33,7 +35,13 @@ export function Deposit({ onDone }: DepositProps) { const t = useTranslations('Deposit') const { toast } = useToast() const { address, sign } = useWallet() - const { sharePrice: livePrice, loading: vaultLoading, error: vaultError, fetchedAt, refresh: refreshVault } = useVault() + const { + sharePrice: livePrice, + loading: vaultLoading, + error: vaultError, + fetchedAt, + refresh: refreshVault, + } = useVault() const [step, setStep] = useState('amount') const [amount, setAmount] = useState('100') const [txHash, setTxHash] = useState(null) @@ -76,7 +84,10 @@ export function Deposit({ onDone }: DepositProps) { } } - const n = parseFloat(amount) || 0 + // Round once to cents and reuse everywhere below so every display of this + // amount agrees, instead of each `.toFixed()` call re-rounding the raw + // float independently (#369). + const n = roundToCents(parseFloat(amount) || 0) const price = livePrice const balance = 240 @@ -149,10 +160,36 @@ export function Deposit({ onDone }: DepositProps) { Using estimated rate )} - {t.rich('preview', { shares: (n / price).toFixed(4), price, num })} - - Fee: < $0.01 · Net proceeds: ≈ {(n - 0.01).toFixed(2)} USDC worth {(n / price).toFixed(4)} HBS (real-time) + {t.rich('preview', { shares: formatDecimal(n / price, 4), price, num })} + + Fee: < $0.01 · Net proceeds: ≈ {formatDecimal(roundToCents(n - 0.01), 2)}{' '} + USDC worth {formatDecimal(n / price, 4)} HBS (real-time) + {n >= 1 && ( + + {t('projection', { + amount: formatDecimal( + roundToCents(projectedReturn(n, HB_DATA.pool.projectedRate)), + 2, + ), + rate: HB_DATA.pool.projectedRate, + })} + + )} ) } @@ -164,7 +201,14 @@ export function Deposit({ onDone }: DepositProps) { style={{ width: '100%', marginTop: 20 }} disabled={n < 1 || n > balance} reason={n > balance ? t('reasonExceeds') : n < 1 ? t('reasonMin') : undefined} - onClick={() => { if (n < 1 || n > balance) { setTxError(n > balance ? 'amount_exceeds_balance' : 'amount_too_low'); setTimeout(()=>scrollToFirstError(document),50); return; } changeStep('review') } } + onClick={() => { + if (n < 1 || n > balance) { + setTxError(n > balance ? 'amount_exceeds_balance' : 'amount_too_low') + setTimeout(() => scrollToFirstError(document), 50) + return + } + changeStep('review') + }} > {n >= 1 && n <= balance ? t('investCta', { amount: n }) : t('investCtaEmpty')} @@ -185,14 +229,30 @@ export function Deposit({ onDone }: DepositProps) { margin: '6px 0 20px', }} > - - + +
-
-

+

+

{isRateStale ? `Rate updated ${rateAgeSeconds}s ago — may be outdated. Refresh before confirming.` : `Live rate — updated ${rateAgeSeconds}s ago at ${priceFetchedAt.toLocaleTimeString()}`} @@ -201,14 +261,37 @@ export function Deposit({ onDone }: DepositProps) { type="button" onClick={() => refreshVault()} aria-label="Refresh exchange rate" - style={{ fontFamily: 'var(--font-body)', fontSize: 'var(--type-caption)', fontWeight: 600, color: 'var(--ink)', background: 'var(--ink-06)', border: '1px solid var(--ink-12)', borderRadius: 'var(--radius-pill)', padding: '4px 10px', cursor: 'pointer' }} + style={{ + fontFamily: 'var(--font-body)', + fontSize: 'var(--type-caption)', + fontWeight: 600, + color: 'var(--ink)', + background: 'var(--ink-06)', + border: '1px solid var(--ink-12)', + borderRadius: 'var(--radius-pill)', + padding: '4px 10px', + cursor: 'pointer', + }} > Refresh rate

{isRateStale && ( -
- Exchange rate is more than 30 seconds old — please refresh to get the latest price before confirming. +
+ Exchange rate is more than 30 seconds old — please refresh to get the latest price + before confirming.
)}

{t.rich('successBody', { - shares: (n / price).toFixed(4), + shares: formatDecimal(n / price, 4), num, b: strong, count: HB_DATA.pool.projectsFunded, diff --git a/src/screens/Landing.tsx b/src/screens/Landing.tsx index d487733b..ac4eedfb 100644 --- a/src/screens/Landing.tsx +++ b/src/screens/Landing.tsx @@ -4,7 +4,6 @@ import { useTranslations } from 'next-intl' import { Button, StatBlock } from '../components' import { LiveHelio } from '../brand/LiveHelio' import { HB_DATA } from '../data' -import type { Screen } from '../types' /** * Landing — public hero. The live Helio dominates; three counters deep-link to @@ -12,10 +11,10 @@ import type { Screen } from '../types' */ export interface LandingProps { onConnect: () => void - onNav: (screen: Screen) => void + onExplore: () => void } -export function Landing({ onConnect, onNav }: LandingProps) { +export function Landing({ onConnect, onExplore }: LandingProps) { const t = useTranslations('Landing') const d = HB_DATA const steps = [1, 2, 3, 4] as const @@ -62,7 +61,7 @@ export function Landing({ onConnect, onNav }: LandingProps) { -

diff --git a/src/screens/Withdraw.tsx b/src/screens/Withdraw.tsx index 45ace9f6..43cfa76e 100644 --- a/src/screens/Withdraw.tsx +++ b/src/screens/Withdraw.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl' import { Button, AmountInput, LiquidityMeter, useToast } from '../components' import { submitWithdraw } from '../wallet/vault' import { useWallet } from '../wallet/WalletProvider' +import { roundToCents, formatDecimal } from '../lib/format' /** * Withdraw — designed with the most care of all. Capped at the live liquid @@ -50,7 +51,8 @@ export function Withdraw({ onDone, onBack }: WithdrawProps) { setStep(newStep) } } - const n = parseFloat(amount) || 0 + // Round once to cents so every display of this amount agrees (#369). + const n = roundToCents(parseFloat(amount) || 0) const renderStep = (currentStep: WithdrawStep) => { switch (currentStep) { @@ -110,7 +112,7 @@ export function Withdraw({ onDone, onBack }: WithdrawProps) { toast({ tone: 'success', title: 'Withdrawal settled', - message: `${n.toFixed(2)} USDC is on its way to your wallet.`, + message: `${formatDecimal(n, 2)} USDC is on its way to your wallet.`, }) } } catch (e) { @@ -219,7 +221,7 @@ export function Withdraw({ onDone, onBack }: WithdrawProps) { }} > {t.rich('successBody', { - amount: n.toFixed(2), + amount: formatDecimal(n, 2), num: (c: ReactNode) => ( {c}