diff --git a/app/interactives/compounding-frequency-calculator/page.tsx b/app/interactives/compounding-frequency-calculator/page.tsx index f23e0aa..be94c55 100644 --- a/app/interactives/compounding-frequency-calculator/page.tsx +++ b/app/interactives/compounding-frequency-calculator/page.tsx @@ -20,10 +20,12 @@ const compoundingOptions: { value: CompoundingPeriod; label: string; periodsPerY { value: "daily", label: "Daily", periodsPerYear: 365 }, ] -function formatCurrency(value: number): string { +function formatCurrency(value: number, decimals: number = 2): string { if (!isFinite(value)) return "-" - if (value >= 1_000_000_000_000) return `$${(value / 1_000_000_000_000).toFixed(2)}T` - if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(2)}B` + if (value >= 1e15) return "Too large to display" + if (value >= 1_000_000_000_000) return `$${(value / 1_000_000_000_000).toFixed(decimals)}T` + if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(decimals)}B` + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(decimals)}M` return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", @@ -32,6 +34,36 @@ function formatCurrency(value: number): string { }).format(value) } +function formatPair(a: number, b: number): { aStr: string; bStr: string; tooLarge: boolean } { + const tooLarge = a >= 1e15 || b >= 1e15 + if (tooLarge) { + return { + aStr: a >= 1e15 ? "Too large to display" : formatCurrency(a), + bStr: b >= 1e15 ? "Too large to display" : formatCurrency(b), + tooLarge: true, + } + } + + // Only apply decimal extension for abbreviated values + const isAbbreviated = a >= 1_000_000 || b >= 1_000_000 + if (!isAbbreviated) { + return { aStr: formatCurrency(a), bStr: formatCurrency(b), tooLarge: false } + } + + let decimals = 2 + while (decimals <= 4) { + const aStr = formatCurrency(a, decimals) + const bStr = formatCurrency(b, decimals) + if (aStr !== bStr || decimals === 4) { + return { aStr, bStr, tooLarge: false } + } + decimals++ + } + + // Unreachable but satisfies TS + return { aStr: formatCurrency(a), bStr: formatCurrency(b), tooLarge: false } +} + function calculateCompoundInterest( principal: number, rate: number, @@ -73,7 +105,7 @@ function buildPeriodsRangeError(compounding: CompoundingPeriod, max: number): st const maxFormatted = max.toLocaleString("en-US") const base = `Enter a number of ${label} between 0 and ${maxFormatted}.` if (compounding === "annually") return base - return `${base} (${maxFormatted} periods = 100 years with ${freqLabels[compounding]} compounding).` + return `${base} (${maxFormatted} ${label} = 100 years with ${freqLabels[compounding]} compounding).` } export default function CompoundInterestCalculator() { @@ -113,6 +145,11 @@ export default function CompoundInterestCalculator() { const [periodsError, setPeriodsError] = useState("") const hasError = !!initialAmountError || !!annualRateError || !!periodsError + const { aStr: balanceStr, bStr: interestStr, tooLarge: mainTooLarge } = formatPair( + selectedResult.finalAmount, + selectedResult.interestEarned + ) + const reset = () => { setInitialAmount("") setAnnualRate("") @@ -185,7 +222,13 @@ export default function CompoundInterestCalculator() { if (initialAmount.startsWith(".")) setInitialAmount("0" + initialAmount); if (!initialAmount) - setTimeout(() => setInitialAmountError("Enter an initial amount."), 150); + setTimeout( + () => + setInitialAmountError( + "Please enter an initial amount.", + ), + 150, + ); }} min="0" className={`block w-full pl-8 rounded-md shadow-sm border [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${initialAmountError ? "border-[var(--color-inline-error)] border-2" : ""}`} @@ -224,9 +267,7 @@ export default function CompoundInterestCalculator() { !isNaN(numericValue) && numericValue > MAX_ANNUAL_RATE ) { - setAnnualRateError( - "Enter a rate between 0% and 1,000%.", - ); + setAnnualRateError("Enter a rate between 0% and 1,000%."); setAnnualRate(numericPart); } else { setAnnualRateError(""); @@ -237,7 +278,11 @@ export default function CompoundInterestCalculator() { if (annualRate.startsWith(".")) setAnnualRate("0" + annualRate); if (!annualRate) - setTimeout(() => setAnnualRateError("Please enter an interest rate."), 150); + setTimeout( + () => + setAnnualRateError("Please enter an interest rate."), + 150, + ); }} className={`block w-full rounded-md shadow-sm py-2 px-3 border pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${annualRateError ? "border-[var(--color-inline-error)] border-2" : ""}`} min="0" @@ -281,13 +326,19 @@ export default function CompoundInterestCalculator() { value={periods} onChange={(e) => { const val = e.target.value; - if (val === "" || Number(val) >= 0) { - setPeriods(val); - if (val !== "" && Number(val) > maxPeriods) { - setPeriodsError(buildPeriodsRangeError(selectedCompounding, maxPeriods)); - } else { - setPeriodsError(""); - } + const stripped = val.replace(/^0+(?=\d)/, ""); + const cleaned = stripped.replace(/(\.\d{2})\d+/, "$1"); + + // Always show what the user typed + setPeriods(cleaned); + + // Error logic runs independently + if (cleaned !== "" && Number(cleaned) > maxPeriods) { + setPeriodsError( + buildPeriodsRangeError(selectedCompounding, maxPeriods), + ); + } else { + setPeriodsError(""); } }} onKeyDown={(e) => { @@ -296,7 +347,13 @@ export default function CompoundInterestCalculator() { onBlur={() => { if (periods.startsWith(".")) setPeriods("0" + periods); if (!periods) - setTimeout(() => setPeriodsError("Enter a number of compounding periods."), 150); + setTimeout( + () => + setPeriodsError( + "Please enter a number of compounding periods.", + ), + 150, + ); }} className={`block w-full rounded-md shadow-sm py-2 px-3 border pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${periodsError ? "border-[var(--color-inline-error)] border-2" : ""}`} min="0" @@ -331,15 +388,19 @@ export default function CompoundInterestCalculator() { id="compounding-frequency" value={selectedCompounding} onChange={(e) => { - const newFreq = e.target.value as CompoundingPeriod - setSelectedCompounding(newFreq) + const newFreq = e.target.value as CompoundingPeriod; + setSelectedCompounding(newFreq); if (periods !== "") { - const newOption = compoundingOptions.find(o => o.value === newFreq)! - const newMax = newOption.periodsPerYear * 100 + const newOption = compoundingOptions.find( + (o) => o.value === newFreq, + )!; + const newMax = newOption.periodsPerYear * 100; if (Number(periods) > newMax) { - setPeriodsError(buildPeriodsRangeError(newFreq, newMax)) + setPeriodsError( + buildPeriodsRangeError(newFreq, newMax), + ); } else { - setPeriodsError("") + setPeriodsError(""); } } }} @@ -378,15 +439,20 @@ export default function CompoundInterestCalculator() { {getPeriodText(selectedCompounding, Number(periods))}

- {hasError ? "-" : formatCurrency(selectedResult.finalAmount)} + {hasError ? "-" : balanceStr}

Interest accrued over {periods}{" "} {getPeriodText(selectedCompounding, Number(periods))}

- {hasError ? "-" : formatCurrency(selectedResult.interestEarned)} + {hasError ? "-" : interestStr}

+ {!hasError && mainTooLarge && ( +

+ Try a lower rate or fewer periods. +

+ )}

With{" "} @@ -447,69 +513,82 @@ export default function CompoundInterestCalculator() { - {comparisonResults.map((result) => ( - - {result.label} - - {result.totalPeriods % 1 === 0 - ? result.totalPeriods.toFixed(0) - : result.totalPeriods.toFixed(2)} - - - {hasError ? "-" : formatCurrency(result.finalAmount)} - - - {hasError ? "-" : formatCurrency(result.interestEarned)} - - - ))} + {comparisonResults.map((result) => { + const { aStr: rowBalance, bStr: rowInterest } = formatPair( + result.finalAmount, + result.interestEarned, + ); + const isSelected = selectedCompounding === result.value; + return ( + + {result.label} + + {result.totalPeriods % 1 === 0 + ? result.totalPeriods.toFixed(0) + : result.totalPeriods.toFixed(2)} + + + {hasError ? "-" : rowBalance} + + + {hasError ? "-" : rowInterest} + + + ); + })} {/* Card layout - visible on small screens only */}

- {comparisonResults.map((result) => ( -
-

{result.label}

-
- Periods - - {result.totalPeriods % 1 === 0 - ? result.totalPeriods.toFixed(0) - : result.totalPeriods.toFixed(2)} - -
-
- Final Amount - - {hasError ? "-" : formatCurrency(result.finalAmount)} - -
-
- Interest Accrued - - {hasError ? "-" : formatCurrency(result.interestEarned)} - + {comparisonResults.map((result) => { + const { aStr: rowBalance, bStr: rowInterest } = formatPair( + result.finalAmount, + result.interestEarned, + ); + return ( +
+

{result.label}

+
+ Periods + + {result.totalPeriods % 1 === 0 + ? result.totalPeriods.toFixed(0) + : result.totalPeriods.toFixed(2)} + +
+
+ Final Amount + + {hasError ? "-" : rowBalance} + +
+
+ + Interest Accrued + + + {hasError ? "-" : rowInterest} + +
-
- ))} + ); + })}

Over the same time period, more frequent compounding results in more diff --git a/app/interactives/present-value-calculator-v2/page.tsx b/app/interactives/present-value-calculator-v2/page.tsx index 26bf490..4093960 100644 --- a/app/interactives/present-value-calculator-v2/page.tsx +++ b/app/interactives/present-value-calculator-v2/page.tsx @@ -14,8 +14,7 @@ import { SelectValue, } from "@/app/ui/components/select" import ThemeToggle from "@/app/lib/theme-toggle" -import { FaCircleInfo } from "react-icons/fa6" - +import InfoPopover from "@/app/ui/components/popover"; type CompoundingFrequency = "annually" | "semi-annually" | "quarterly" | "monthly" | "biweekly" | "weekly" | "daily" @@ -89,7 +88,8 @@ export default function PresentValueCalculator() { // Warning states (amber — calc still runs) const [interestRateWarning, setInterestRateWarning] = useState("") const [paymentInterestRateWarning, setPaymentInterestRateWarning] = useState("") - const [futureValueWarning, setFutureValueWarning] = useState("") + const [timePeriodWarning, setTimePeriodWarning] = useState("") + const [numberOfPaymentsWarning, setNumberOfPaymentsWarning] = useState("") // Derived max periods for each tab const singleMaxPeriods = frequencyMap[compoundingFrequency].periods * 100 @@ -135,10 +135,10 @@ export default function PresentValueCalculator() { setTimePeriod("") setCompoundingFrequency("annually") setFutureValueError("") - setFutureValueWarning("") setInterestRateError("") setTimePeriodError("") setInterestRateWarning("") + setTimePeriodWarning("") } const resetSeries = () => { @@ -152,6 +152,7 @@ export default function PresentValueCalculator() { setNumberOfPaymentsError("") setFinalAmountError("") setPaymentInterestRateWarning("") + setNumberOfPaymentsWarning("") } const singleCalculations = useMemo(() => { @@ -254,7 +255,6 @@ export default function PresentValueCalculator() { const raw = e.target.value; if (raw === "") { setFutureValueError(""); - setFutureValueWarning(""); setFutureValue(""); return; } @@ -263,16 +263,10 @@ export default function PresentValueCalculator() { setFutureValueError( "Enter an amount between 0 and 1,000,000,000.", ); - setFutureValueWarning(""); setFutureValue(raw); return; } setFutureValueError(""); - setFutureValueWarning( - val === 0 - ? "A future value of $0 has no present value to calculate." - : "", - ); setFutureValue(raw); }} onBlur={(e) => { @@ -280,7 +274,6 @@ export default function PresentValueCalculator() { const val = parseFloat(raw); if (raw === "" || isNaN(val)) { setFutureValue(""); - setFutureValueWarning(""); setTimeout( () => setFutureValueError( @@ -292,15 +285,10 @@ export default function PresentValueCalculator() { setFutureValue(String(val)); } else { setFutureValueError(""); - setFutureValueWarning( - val === 0 - ? "A future value of $0 has no present value to calculate." - : "", - ); setFutureValue(String(val)); } }} - className={`border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${parseFloat(futureValue) > 0 ? "pl-7" : "pl-8"} ${futureValueError ? "border-[var(--color-inline-error)] border-2" : futureValueWarning ? "border-amber-500 border-2" : ""}`} + className={`border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${parseFloat(futureValue) > 0 ? "pl-7" : "pl-8"} ${futureValueError ? "border-[var(--color-inline-error)] border-2" : ""}`} min={0} max={1000000000} /> @@ -313,11 +301,6 @@ export default function PresentValueCalculator() { {futureValueError}

)} - {futureValueWarning && ( -

- {futureValueWarning} -

- )}
{/* Interest Rate */} @@ -431,21 +414,27 @@ export default function PresentValueCalculator() { if (raw === "") { setTimePeriod(""); setTimePeriodError(""); + setTimePeriodWarning(""); return; } const val = Number(raw); + setTimePeriod(raw); if (val < 0 || val > singleMaxPeriods) { - setTimePeriod(""); setTimePeriodError( buildPeriodsRangeError( compoundingFrequency, singleMaxPeriods, ), ); - return; + setTimePeriodWarning(""); + } else { + setTimePeriodError(""); + setTimePeriodWarning( + val === 0 + ? "0 periods = today. No periodic payments occur. Only a final amount today affects the present value." + : "", + ); } - setTimePeriodError(""); - setTimePeriod(raw); }} onBlur={(e) => { const raw = e.target.value; @@ -477,6 +466,11 @@ export default function PresentValueCalculator() { {timePeriodError}

)} + {timePeriodWarning && !timePeriodError && ( +

+ {timePeriodWarning} +

+ )} {/* Compounding Frequency */} @@ -665,21 +659,10 @@ export default function PresentValueCalculator() { Final amount (optional)
- - +
@@ -853,21 +836,27 @@ export default function PresentValueCalculator() { if (raw === "") { setNumberOfPayments(""); setNumberOfPaymentsError(""); + setNumberOfPaymentsWarning(""); return; } const val = Number(raw); + setNumberOfPayments(raw); if (val < 0 || val > seriesMaxPeriods) { - setNumberOfPayments(""); setNumberOfPaymentsError( buildPeriodsRangeError( paymentFrequency, seriesMaxPeriods, ), ); - return; + setNumberOfPaymentsWarning(""); + } else { + setNumberOfPaymentsError(""); + setNumberOfPaymentsWarning( + val === 0 + ? "0 periods = today. No periodic payments occur. Only a final amount today affects the present value." + : "", + ); } - setNumberOfPaymentsError(""); - setNumberOfPayments(raw); }} onBlur={(e) => { const raw = e.target.value; @@ -899,6 +888,11 @@ export default function PresentValueCalculator() { {numberOfPaymentsError}

)} + {numberOfPaymentsWarning && !numberOfPaymentsError && ( +

+ {numberOfPaymentsWarning} +

+ )}
{/* Payment Frequency */}