diff --git a/app/interactives/interest-calculator/page.tsx b/app/interactives/interest-calculator/page.tsx index 5481441..34b97de 100644 --- a/app/interactives/interest-calculator/page.tsx +++ b/app/interactives/interest-calculator/page.tsx @@ -1,9 +1,9 @@ "use client"; import React, { useState, useEffect, useMemo } from "react"; -import { FaPiggyBank } from "react-icons/fa"; -import { FaArrowTrendDown, FaAngleDown } from "react-icons/fa6"; +import { FaAngleDown } from "react-icons/fa6"; import ThemeToggle from "@/app/lib/theme-toggle"; +import { Button } from "@/app/ui/components/button" type CompoundingFrequency = | "daily" @@ -44,6 +44,16 @@ const freqAdjective: Record = { annually: "annual", }; +// Maximum span the periods field allows, expressed in years. +const MAX_YEARS = 300; + +// Any result at or beyond this magnitude is treated as too large to display. +const DISPLAY_CEILING = 1e15; + +// Catches Infinity, -Infinity, NaN, or anything past the display ceiling. +const isTooLarge = (value: number) => + !Number.isFinite(value) || Math.abs(value) > DISPLAY_CEILING; + function buildPeriodsRangeError( freq: CompoundingFrequency, max: number, @@ -53,9 +63,26 @@ function buildPeriodsRangeError( if (freq === "annually") { return `Enter a number of years between 0 and ${maxFormatted}.`; } - return `Enter a number of ${periodLabel} between 0 and ${maxFormatted}. (${maxFormatted} ${periodLabel} = 100 years with ${freqAdjective[freq]} compounding).`; + return `Enter a number of ${periodLabel} between 0 and ${maxFormatted}. (${maxFormatted} ${periodLabel} = ${MAX_YEARS} years with ${freqAdjective[freq]} compounding).`; } +// Adds thousands separators while preserving a leading minus sign and a decimal +// point the user is still typing (e.g. "-" stays "-", "1000." stays "1,000.", +// ".5" stays ".5", "-1000" stays "-1,000"). +const formatWithCommas = (raw: string): string => { + if (raw === "" || raw === "-") return raw; + const negative = raw.startsWith("-"); + const unsigned = negative ? raw.slice(1) : raw; + const sign = negative ? "-" : ""; + const [intPart, decPart] = unsigned.split("."); + const intFormatted = + intPart === "" ? "" : parseInt(intPart, 10).toLocaleString("en-US"); + if (unsigned.includes(".")) { + return `${sign}${intFormatted}.${decPart ?? ""}`; + } + return `${sign}${intFormatted}`; +}; + const formatCurrency = (value: number) => new Intl.NumberFormat("en-US", { style: "currency", @@ -68,21 +95,25 @@ const AMOUNT_MAX = 100_000_000; const AMOUNT_MIN = 1; const RATE_MAX = 1000; +// Whole dollars (8) + decimal point + 2 cents = 12 characters of headroom. +// The optional minus sign is not counted against this budget. +const AMOUNT_MAX_CHARS = 12; + export default function InterestRateVisual() { const [mode, setMode] = useState<"saving" | "borrowing">("saving"); // Amount - const [amountRaw, setAmountRaw] = useState("100"); - const [amountDisplay, setAmountDisplay] = useState("100"); + const [amountRaw, setAmountRaw] = useState(""); + const [amountDisplay, setAmountDisplay] = useState(""); const [amountError, setAmountError] = useState(""); // Rate - const [rateRaw, setRateRaw] = useState("5"); + const [rateRaw, setRateRaw] = useState(""); const [rateError, setRateError] = useState(""); const [rateWarning, setRateWarning] = useState(""); // Periods - const [periodsRaw, setPeriodsRaw] = useState("10"); + const [periodsRaw, setPeriodsRaw] = useState(""); const [periodsError, setPeriodsError] = useState(""); const [periodsWarning, setPeriodsWarning] = useState(""); const [periodsInfo, setPeriodsInfo] = useState(""); @@ -93,9 +124,9 @@ export default function InterestRateVisual() { // Debounced values for calculation const [debounced, setDebounced] = useState({ - amount: "100", - rate: "5", - periods: "10", + amount: "", + rate: "", + periods: "", compounding: "annually" as CompoundingFrequency, }); @@ -113,16 +144,14 @@ export default function InterestRateVisual() { return () => clearTimeout(t); }, [amountRaw, rateRaw, periodsRaw, compounding]); - const maxPeriods = frequencyMap[compounding].periods * 100; + const maxPeriods = frequencyMap[compounding].periods * MAX_YEARS; // Derived error state - const hasError = - amountRaw === "" || - rateRaw === "" || - periodsRaw === "" || - !!amountError || - !!rateError || - !!periodsError; + const anyFieldEmpty = + amountRaw === "" || rateRaw === "" || periodsRaw === ""; + const hasValidationError = + !!amountError || !!rateError || !!periodsError; + const hasError = anyFieldEmpty || hasValidationError; // Calculations const { interestAmount, totalAmount } = useMemo(() => { @@ -146,38 +175,68 @@ export default function InterestRateVisual() { }; }, [debounced, hasError, mode]); + // A finite, valid calculation whose magnitude is beyond what we can render. + const resultTooLarge = + !hasError && (isTooLarge(interestAmount) || isTooLarge(totalAmount)); + + // Reset everything back to the empty default state. + const handleReset = () => { + setMode("saving"); + setAmountRaw(""); + setAmountDisplay(""); + setAmountError(""); + setRateRaw(""); + setRateError(""); + setRateWarning(""); + setPeriodsRaw(""); + setPeriodsError(""); + setPeriodsWarning(""); + setPeriodsInfo(""); + setCompounding("annually"); + setDebounced({ + amount: "", + rate: "", + periods: "", + compounding: "annually", + }); + }; + // Amount handlers const handleAmountChange = (e: React.ChangeEvent) => { const stripped = e.target.value.replace(/,/g, ""); - if (stripped !== "" && !/^\d*$/.test(stripped)) return; + // Allow empty, an optional leading minus, digits, an optional single decimal + // point, and up to 2 decimals. The minus is kept so an invalid negative + // entry stays visible and can be flagged rather than silently cleared. + if (stripped !== "" && !/^-?\d*\.?\d{0,2}$/.test(stripped)) return; + // Count value digits only; the sign does not eat into the character budget. + if (stripped.replace("-", "").length > AMOUNT_MAX_CHARS) return; setAmountRaw(stripped); - const num = parseInt(stripped, 10); + setAmountDisplay(formatWithCommas(stripped)); + const num = parseFloat(stripped); if (!isNaN(num)) { - setAmountDisplay(num.toLocaleString("en-US")); if (num < AMOUNT_MIN || num > AMOUNT_MAX) { setAmountError( - `Enter a whole dollar amount between $${AMOUNT_MIN} and $${AMOUNT_MAX.toLocaleString("en-US")}.`, + `Enter an amount between $${AMOUNT_MIN} and $${AMOUNT_MAX.toLocaleString("en-US")}.`, ); } else { setAmountError(""); } } else { - setAmountDisplay(stripped); setAmountError(""); } }; const handleAmountBlur = () => { - const num = parseInt(amountRaw, 10); + const num = parseFloat(amountRaw); if (amountRaw === "" || isNaN(num)) { setAmountRaw(""); setAmountDisplay(""); setTimeout(() => setAmountError("Please enter an initial amount."), 150); } else { - setAmountDisplay(num.toLocaleString("en-US")); + setAmountDisplay(num.toLocaleString("en-US", { maximumFractionDigits: 2 })); if (num < AMOUNT_MIN || num > AMOUNT_MAX) { setAmountError( - `Enter a whole dollar amount between $${AMOUNT_MIN} and $${AMOUNT_MAX.toLocaleString("en-US")}.`, + `Enter an amount between $${AMOUNT_MIN} and $${AMOUNT_MAX.toLocaleString("en-US")}.`, ); } else { setAmountError(""); @@ -203,7 +262,7 @@ export default function InterestRateVisual() { setRateError(""); setRateWarning( val === 0 - ? "At 0%, no interest is earned or charged — final amount equals initial amount." + ? "At 0%, no interest is earned or charged. Final amount equals initial amount." : "", ); } @@ -238,7 +297,7 @@ export default function InterestRateVisual() { setPeriodsError(""); setPeriodsWarning( val === 0 - ? "0 periods means no time passes — final amount will equal the initial amount." + ? "0 periods means no time passes. Final amount will equal the initial amount." : "", ); setPeriodsInfo( @@ -266,7 +325,7 @@ export default function InterestRateVisual() { const freq = e.target.value as CompoundingFrequency; setCompounding(freq); if (periodsRaw !== "") { - const newMax = frequencyMap[freq].periods * 100; + const newMax = frequencyMap[freq].periods * MAX_YEARS; const val = parseFloat(periodsRaw); if (val > newMax) { setPeriodsError(buildPeriodsRangeError(freq, newMax)); @@ -282,21 +341,16 @@ export default function InterestRateVisual() {

Interest Calculator

- {/* Mode toggle */} + {/* Mode toggle (spans the full width above the two columns) */}

I am:

+
+
+ + {/* RIGHT: results */} +
+

+ {mode === "saving" ? "What you'll have" : "What you'd owe"} +

+
+
+ {hasError && ( +

+ {hasValidationError + ? "Fix the highlighted field to see results." + : ""} +

+ )} + {/* Too-large remedial line, shown once under the results */} + {resultTooLarge && ( +

+ Try a lower rate or fewer periods. +

+ )} + + {/* Second helper: borrowing context, shown under the results */} + {mode === "borrowing" && !hasError && ( +

+ This shows how the balance grows if left unpaid. +

+ )} + + {/* Initial amount row */} +
+
+ Initial amount: +
+
+ {hasError ? "-" : formatCurrency(parseFloat(amountRaw) || 0)} +
+ + {/* Interest row */}
- {hasError ? "-" : formatCurrency(Math.abs(interestAmount))} +
+ {mode === "saving" ? "Interest earned" : "Interest paid"}: +
+
+ {hasError + ? "-" + : resultTooLarge + ? "Too large to display" + : formatCurrency(Math.abs(interestAmount))} +
-
- {/* Final amount row */} -
-
- Final amount: -
-
- {hasError ? "-" : formatCurrency(totalAmount)} + {/* Final amount row */} +
+
+ Final amount: +
+
+ {hasError + ? "-" + : resultTooLarge + ? "Too large to display" + : formatCurrency(totalAmount)} +
-
- {/* Explanation */} -
- {mode === "saving" ? ( -

- When you save: -

- ) : ( -

- When you - borrow: -

- )} -

- {mode === "saving" - ? "You are essentially a lender, and you get interest from those using your money." - : "You are paying interest for the privilege of using someone else's money."} -

+ {/* Explanation */} +
+ {mode === "saving" ? ( +

+ When you save: +

+ ) : ( +

+ When you borrow: +

+ )} +

+ {mode === "saving" + ? "You are essentially a lender, and you get interest from those using your money." + : "You are paying interest for the privilege of using someone else's money."} +

+
diff --git a/app/interactives/present-value-calculator-v2/page.tsx b/app/interactives/present-value-calculator-v2/page.tsx index e429c8b..0925bae 100644 --- a/app/interactives/present-value-calculator-v2/page.tsx +++ b/app/interactives/present-value-calculator-v2/page.tsx @@ -109,6 +109,7 @@ export default function PresentValueCalculator() { // Whether each tab has a blocking error (or empty required field) that should suppress results. const singleHasError = singleAnyFieldEmpty || !!futureValueError || !!interestRateError || !!timePeriodError const seriesHasError = seriesAnyFieldEmpty || !!paymentAmountError || !!finalAmountError || !!paymentInterestRateError || !!numberOfPaymentsError + const seriesHasValidationError = !!paymentAmountError || !!finalAmountError || !!paymentInterestRateError || !!numberOfPaymentsError // Debounced inputs for calculations — updates after 300ms pause in typing // to avoid recalculating on every keystroke. @@ -1004,7 +1005,7 @@ export default function PresentValueCalculator() { ? "—" : formatCurrency(paymentCalculations.presentValue)}

- {seriesHasError && ( + {seriesHasValidationError && (

Fix the fields on the left to see results.