From 595d52968b5d0acde6c6a4201badc4e65783e329 Mon Sep 17 00:00:00 2001 From: Jen Breese-Kauth Date: Thu, 2 Jul 2026 12:06:29 -0700 Subject: [PATCH 1/4] Changes to the math, errors, layout --- app/interactives/interest-calculator/page.tsx | 606 ++++++++++-------- 1 file changed, 345 insertions(+), 261 deletions(-) diff --git a/app/interactives/interest-calculator/page.tsx b/app/interactives/interest-calculator/page.tsx index 5481441..4ef9984 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 */} +
+

+ Results: +

+
+
+ {hasError && ( +

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

+ )} + + {/* 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)} +
+ + {/* Too-large remedial line, shown once under the results */} + {resultTooLarge && ( +

+ Try a lower rate or fewer periods. +

+ )}
-
- {/* 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."} +

+
From 5bc8f76f01f496f73de3e0650bac755cda124ca7 Mon Sep 17 00:00:00 2001 From: Jen Breese-Kauth Date: Thu, 2 Jul 2026 13:40:38 -0700 Subject: [PATCH 2/4] fixup: --- app/interactives/interest-calculator/page.tsx | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/app/interactives/interest-calculator/page.tsx b/app/interactives/interest-calculator/page.tsx index 4ef9984..34b97de 100644 --- a/app/interactives/interest-calculator/page.tsx +++ b/app/interactives/interest-calculator/page.tsx @@ -346,7 +346,7 @@ export default function InterestRateVisual() {

I am: