diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..defbd45 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +Releases are identified by deploy date. + +## [Unreleased] + +## [2026-07-10] + +First tracked production release. Promotes the accumulated calculator work from +late June through 10 July (PR [#157](https://github.com/SU-SWS/ifdm_learning_apps/pull/157), +`dev` into `1.x`). All blocker and high-priority issues raised during review are resolved. + +### Added + +- Inline validation and error messaging for the Time Value of Money calculator, per the TVM error document (#134, #146). +- Inline validation and error messaging for the Compounding Frequency calculator (#140). +- Inline validation and error messaging for the Inflation Impact calculator (#141). +- Error states and input standards for the Interest calculator (#142). +- Inline validation and error messaging for the Mortgage v2 calculator (#145). +- Input defaulting for the TVM Present Value, Future Value, and Payment fields so blank fields resolve to sensible defaults (#144). + +### Changed + +- Interest calculator math, error handling, and layout updated to match spec (#148). +- Compounding Frequency default period changed to Annual (#147). +- Default compounding frequency set for the TVM calculator (#144). +- Color tokens updated and corrected across calculators (#152). +- UI cleanup plus label and punctuation corrections to match design (#134, #148). + +### Fixed + +- TVM interest-rate solver now returns the correct rate for high-rate and edge-case scenarios (#152). +- TVM interest-rate solver now handles high and mixed-frequency (cross-compounding) rates correctly (#154). +- TVM edge-case math error in the rate calculation (#152). +- TVM zero-rate present value calculation (#144). +- TVM interest-rate calculator correction (#146). +- Inflation Impact currency input no longer corrupts the value when a field is re-edited, and the empty-field error no longer fires at the wrong time (#153). +- Inflation Impact onChange handler now accepts valid entries, including negative values (#151). +- Interest calculator percent edge cases (#156). +- Interest calculator interest-rate calculation (#142). +- All remaining blocker and high-priority issues cleared ahead of the production release (#156). diff --git a/app/interactives/compounding-frequency-calculator/page.tsx b/app/interactives/compounding-frequency-calculator/page.tsx index 951ff13..043bb4d 100644 --- a/app/interactives/compounding-frequency-calculator/page.tsx +++ b/app/interactives/compounding-frequency-calculator/page.tsx @@ -121,7 +121,7 @@ export default function CompoundInterestCalculator() { const [initialAmount, setInitialAmount] = useState("") const [annualRate, setAnnualRate] = useState("") const [periods, setPeriods] = useState("") - const [selectedCompounding, setSelectedCompounding] = useState("monthly") + const [selectedCompounding, setSelectedCompounding] = useState("annually") // Error state is declared early so setters are available to flagSkippedFields. const [initialAmountError, setInitialAmountError] = useState("") @@ -505,19 +505,19 @@ export default function CompoundInterestCalculator() {

- Balance after{" "} + Balance after {hasError - ? "-" - : `${periods} ${getPeriodText(selectedCompounding, Number(periods))}`} + ? "" + : ` ${periods} ${getPeriodText(selectedCompounding, Number(periods))}`}

{hasError ? "-" : balanceStr}

- Interest accrued over{" "} + Interest accrued over {hasError - ? "-" - : `${periods} ${getPeriodText(selectedCompounding, Number(periods))}`} + ? "" + : ` ${periods} ${getPeriodText(selectedCompounding, Number(periods))}`}

{hasError ? "-" : interestStr} diff --git a/app/interactives/inflation-impact-calculator/page.tsx b/app/interactives/inflation-impact-calculator/page.tsx index b1316db..49fde2a 100644 --- a/app/interactives/inflation-impact-calculator/page.tsx +++ b/app/interactives/inflation-impact-calculator/page.tsx @@ -1,98 +1,182 @@ "use client"; -import { useState, useEffect } from "react" -import { Card, CardContent, CardHeader, CardTitle } from "@/app/ui/components/card" -import { Input } from "@/app/ui/components/input" -import { Label } from "@/app/ui/components/label" -import { CustomSlider } from "@/app/ui/components/slider" -import { Badge } from "@/app/ui/components/badge" -import { BiSolidUpArrow, BiSolidDownArrow } from "react-icons/bi"; +import { useState, useEffect } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/app/ui/components/card"; +import { Input } from "@/app/ui/components/input"; +import { Label } from "@/app/ui/components/label"; +import { CustomSlider } from "@/app/ui/components/slider"; +import { Badge } from "@/app/ui/components/badge"; import ThemeToggle from "@/app/lib/theme-toggle"; +const INITIAL_PRICE_MIN = 0.01; +const INITIAL_PRICE_MAX = 1_000_000_000; + +type PriceError = "empty" | "range" | null; + +function getPriceError(raw: string, numeric: number): PriceError { + if (raw === "") return "empty"; + if (numeric < INITIAL_PRICE_MIN || numeric > INITIAL_PRICE_MAX) + return "range"; + return null; +} + +function formatWithCommas(value: number): string { + return value.toLocaleString("en-US", { maximumFractionDigits: 2 }); +} + +const formatCurrency = (value: number) => + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); + export default function InflationCalculator() { - const [initialPrice, setInitialPrice] = useState(100) - const [inflationRate, setInflationRate] = useState(3.5) - const [timePeriod, setTimePeriod] = useState(10) - const [futureValue, setFutureValue] = useState(0) + const [initialPriceRaw, setInitialPriceRaw] = useState("100"); + const [initialPriceDisplay, setInitialPriceDisplay] = useState("100"); + const [priceBlurred, setPriceBlurred] = useState(false); + const [inflationRate, setInflationRate] = useState(3.5); + const [timePeriod, setTimePeriod] = useState(10); + const [futureValue, setFutureValue] = useState(0); + + + const numericPrice = parseFloat(initialPriceRaw); + const priceError: PriceError = getPriceError(initialPriceRaw, numericPrice); + const isValid = priceError === null && !isNaN(numericPrice); + + // Range errors (including negatives) show live so the user sees the mistake as + // they type. The empty-field prompt waits until they have left the field. + const showRangeError = priceError === "range"; + const showEmptyError = priceError === "empty" && priceBlurred; + const showError = showRangeError || showEmptyError; - // Calculate future value based on compound inflation useEffect(() => { - const future = initialPrice * Math.pow(1 + inflationRate / 100, timePeriod) - setFutureValue(future) - }, [initialPrice, inflationRate, timePeriod]) + if (!isValid) { + setFutureValue(null); + return; + } + const future = numericPrice * Math.pow(1 + inflationRate / 100, timePeriod); + setFutureValue(future); + }, [initialPriceRaw, inflationRate, timePeriod, isValid, numericPrice]); const getInflationLabel = (rate: number) => { - if (rate <= 2.9) return "Low" - if (rate <= 6) return "Moderate" - if (rate <= 10) return "High" - return "Very high" - } + if (rate <= 2.9) return "Low"; + if (rate <= 6) return "Moderate"; + if (rate <= 10) return "High"; + return "Very high"; + }; const getInflationColor = (rate: number) => { - if (rate <= 2.9) return "bg-badge-green" // Green - if (rate <= 6) return "bg-badge-yellow" // Yellow - if (rate <= 10) return "bg-badge-orange" // Orange - return "bg-badge-red" // Red - } + if (rate <= 2.9) return "bg-badge-green"; + if (rate <= 6) return "bg-badge-yellow"; + if (rate <= 10) return "bg-badge-orange"; + return "bg-badge-red"; + }; + + const priceErrorMessage = showRangeError + ? "Enter an amount between $0.01 and $1,000,000,000." + : showEmptyError + ? "Enter an initial amount to see the impact of inflation over time." + : null; return (

- {/* Header */}

Inflation Impact Calculator

{/* Parameters Panel */} - {/* Future Value Input */} -
+ {/* Initial Price */} +
- - setInitialPrice(Number(e.target.value) || 0)} - className="bg-[var(--input-background)] text-[var(--input-text)] bg-[var(--input-border)] text-md font-bold 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" - min="1" - max="1000000" - /> -
- {/* Increment/Decrement buttons for Future Value */} - - + $ + + { + const stripped = e.target.value.replace(/,/g, ""); + if (stripped !== "" && !/^-?\d*\.?\d*$/.test(stripped)) return; + setInitialPriceRaw(stripped); + setInitialPriceDisplay(stripped); // no comma formatting mid-edit + }} + onFocus={() => { + setPriceBlurred(false); + setInitialPriceDisplay(initialPriceRaw); + }} + onBlur={() => { + setPriceBlurred(true); + const num = parseFloat(initialPriceRaw); + if (!isNaN(num)) { + setInitialPriceDisplay(formatWithCommas(num)); + } else { + setInitialPriceDisplay(initialPriceRaw); + } + }} + aria-invalid={showError} + aria-describedby="initial-price-error" + className={`bg-[var(--input-background)] text-[var(--input-text)] text-md w-full rounded-md shadow-sm pl-7 border pr-10 ${ + showError + ? "border-2 border-[var(--color-inline-error)]" + : "bg-[var(--input-border)]" + }`} + />
+ {/* Always rendered so aria-describedby always resolves */} +
- {/* Interest Rate Slider */} -
-
- - - {inflationRate.toFixed(1)}% - {getInflationLabel(inflationRate)} - -
- +
+ + + {inflationRate.toFixed(1)}%{" "} + {getInflationLabel(inflationRate)} + +
+ setInflationRate(value[0])} max={15} @@ -100,7 +184,10 @@ export default function InflationCalculator() { step={0.1} className="w-full" /> -
+ @@ -108,13 +195,28 @@ export default function InflationCalculator() { {/* Time Period Slider */}
-
- - +
+ + {timePeriod} year{timePeriod !== 1 ? "s" : ""}
setTimePeriod(value[0])} max={50} @@ -123,7 +225,10 @@ export default function InflationCalculator() { className="w-full" rangeClassName="time-period-range bg-lagunita-light" /> -
+ @@ -131,31 +236,30 @@ export default function InflationCalculator() { - {/* Present Value Calculation Panel */} + {/* Results Panel */} - Results: + + Results: + - - {/* Main Result */} - - After {timePeriod} year{timePeriod !== 1 ? "s" : ""} - - {/* Calculation Breakdown */} -
- {/* Results section */} -
-
-
-
- Future price: -
-
- {`$${futureValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} -
-
+ +
+ + After {timePeriod} year{timePeriod !== 1 ? "s" : ""} + +
+
+
+ Future price: +
+
+ {futureValue !== null ? formatCurrency(futureValue) : "—"}
- {/* Wrapper section ends */} +
@@ -163,5 +267,5 @@ export default function InflationCalculator() {
- ) + ); } diff --git a/app/interactives/interest-calculator/page.tsx b/app/interactives/interest-calculator/page.tsx index 706bc6a..87b8cee 100644 --- a/app/interactives/interest-calculator/page.tsx +++ b/app/interactives/interest-calculator/page.tsx @@ -1,124 +1,362 @@ "use client"; -import React, { useState, useEffect } from "react"; -import { FaPiggyBank } from "react-icons/fa"; -import { FaArrowTrendDown, FaAngleDown } from "react-icons/fa6"; -import { BiSolidUpArrow, BiSolidDownArrow } from "react-icons/bi"; +import React, { useState, useEffect, useMemo } from "react"; +import { FaAngleDown } from "react-icons/fa6"; import ThemeToggle from "@/app/lib/theme-toggle"; +import { Button } from "@/app/ui/components/button" -const InterestRateVisual = () => { - const [mode, setMode] = useState("saving"); // 'saving' or 'borrowing' - const [amount, setAmount] = useState(100); - const [interestRate, setInterestRate] = useState(5); - const [periods, setPeriods] = useState(10); - const [compounding, setCompounding] = useState("annually"); +type CompoundingFrequency = + | "daily" + | "weekly" + | "bi-weekly" + | "monthly" + | "quarterly" + | "semi-annually" + | "annually"; - const [interestAmount, setInterestAmount] = useState(0); - const [totalAmount, setTotalAmount] = useState(0); +const frequencyMap: Record< + CompoundingFrequency, { periods: number; label: string; periodLabel: string } +> = { + daily: { periods: 365, label: "Daily", periodLabel: "days" }, + weekly: { periods: 52, label: "Weekly", periodLabel: "weeks" }, + "bi-weekly": { + periods: 26, + label: "Bi-weekly", + periodLabel: "bi-weekly periods", + }, + monthly: { periods: 12, label: "Monthly", periodLabel: "months" }, + quarterly: { periods: 4, label: "Quarterly", periodLabel: "quarters" }, + "semi-annually": { + periods: 2, + label: "Semi-annually", + periodLabel: "semi-annual periods", + }, + annually: { periods: 1, label: "Annually", periodLabel: "years" }, +}; + +const freqAdjective: Record = { + daily: "daily", + weekly: "weekly", + "bi-weekly": "biweekly", + monthly: "monthly", + quarterly: "quarterly", + "semi-annually": "semiannual", + 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, +): string { + const { periodLabel } = frequencyMap[freq]; + const maxFormatted = max.toLocaleString("en-US"); + 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} = ${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", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); + +const AMOUNT_MAX = 100_000_000; +const AMOUNT_MIN = 1; +const RATE_MAX = 1000; + +// Whole dollars (9) + 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(""); + const [amountDisplay, setAmountDisplay] = useState(""); + const [amountError, setAmountError] = useState(""); + + // Rate + const [rateRaw, setRateRaw] = useState(""); + const [rateError, setRateError] = useState(""); + const [rateWarning, setRateWarning] = useState(""); + + // Periods + const [periodsRaw, setPeriodsRaw] = useState(""); + const [periodsError, setPeriodsError] = useState(""); + const [periodsWarning, setPeriodsWarning] = useState(""); + + // Compounding + const [compounding, setCompounding] = + useState("annually"); + + // Debounced values for calculation + const [debounced, setDebounced] = useState({ + amount: "", + rate: "", + periods: "", + compounding: "annually" as CompoundingFrequency, + }); - // Calculate the interest and total amount useEffect(() => { - let periodsPerYear = 1; - switch (compounding) { - case "daily": - periodsPerYear = 365; - break; - case "weekly": - periodsPerYear = 52; - break; - case "bi-weekly": - periodsPerYear = 26; - break; - case "monthly": - periodsPerYear = 12; - break; - case "quarterly": - periodsPerYear = 4; - break; - case "semi-annually": - periodsPerYear = 2; - break; - default: - periodsPerYear = 1; - } + const t = setTimeout( + () => + setDebounced({ + amount: amountRaw, + rate: rateRaw, + periods: periodsRaw, + compounding, + }), + 300, + ); + return () => clearTimeout(t); + }, [amountRaw, rateRaw, periodsRaw, compounding]); + + const maxPeriods = frequencyMap[compounding].periods * MAX_YEARS; + + // Derived error state + const anyFieldEmpty = + amountRaw === "" || rateRaw === "" || periodsRaw === ""; + const hasValidationError = + !!amountError || !!rateError || !!periodsError; + const hasError = anyFieldEmpty || hasValidationError; + + // Calculations + const { interestAmount, totalAmount } = useMemo(() => { + if (hasError) return { interestAmount: 0, totalAmount: 0 }; - // Compound interest formula: A = P(1 + r/n)^(t) - // where t is the number of compounding periods - const rate = interestRate / 100; + const amount = parseFloat(debounced.amount) || 0; + const rate = (parseFloat(debounced.rate) || 0) / 100; + const periodsPerYear = frequencyMap[debounced.compounding].periods; const periodicRate = rate / periodsPerYear; + const periods = parseFloat(debounced.periods) || 0; const calculatedTotal = amount * Math.pow(1 + periodicRate, periods); const calculatedInterest = calculatedTotal - amount; - setInterestAmount( - mode === "saving" ? calculatedInterest : -calculatedInterest - ); - setTotalAmount( - mode === "saving" ? calculatedTotal : amount + calculatedInterest - ); - }, [amount, interestRate, periods, compounding, mode]); + // Saving and borrowing share the same magnitude; mode only affects the + // surrounding copy and colors, not the numbers. Display uses Math.abs on + // interest, so no sign handling is needed here. + return { + interestAmount: calculatedInterest, + totalAmount: calculatedTotal, + }; + }, [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(""); + setCompounding("annually"); + setDebounced({ + amount: "", + rate: "", + periods: "", + compounding: "annually", + }); + }; + + // Amount handlers + const handleAmountChange = (e: React.ChangeEvent) => { + const stripped = e.target.value.replace(/,/g, ""); + // 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); + setAmountDisplay(formatWithCommas(stripped)); + const num = parseFloat(stripped); + if (!isNaN(num)) { + if (num < AMOUNT_MIN || num > AMOUNT_MAX) { + setAmountError( + `Enter an amount between $${AMOUNT_MIN} and $${AMOUNT_MAX.toLocaleString("en-US")}.`, + ); + } else { + setAmountError(""); + } + } else { + setAmountError(""); + } + }; + + const handleAmountBlur = () => { + const num = parseFloat(amountRaw); + if (amountRaw === "" || isNaN(num)) { + setAmountRaw(""); + setAmountDisplay(""); + setTimeout(() => setAmountError("Please enter an initial amount."), 150); + } else { + setAmountDisplay(num.toLocaleString("en-US", { maximumFractionDigits: 2 })); + if (num < AMOUNT_MIN || num > AMOUNT_MAX) { + setAmountError( + `Enter an amount between $${AMOUNT_MIN} and $${AMOUNT_MAX.toLocaleString("en-US")}.`, + ); + } else { + setAmountError(""); + } + } + }; + + // Rate handlers + const handleRateChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + if (raw === "") { + setRateRaw(""); + setRateError(""); + setRateWarning(""); + return; + } + const val = parseFloat(raw); + setRateRaw(raw); + if (val < 0 || val > RATE_MAX) { + setRateError("Enter a rate between 0% and 1,000%."); + setRateWarning(""); + } else { + setRateError(""); + setRateWarning( + val === 0 + ? "At 0%, no interest is earned or charged. Final amount equals initial amount." + : "", + ); + } + }; + + const handleRateBlur = (e: React.FocusEvent) => { + const raw = e.target.value; + if (raw === "" || isNaN(parseFloat(raw))) { + setRateRaw(""); + setRateWarning(""); + setTimeout(() => setRateError("Please enter an interest rate."), 150); + } + }; + + // Periods handlers + const handlePeriodsChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + if (raw === "") { + setPeriodsRaw(""); + setPeriodsError(""); + setPeriodsWarning(""); + return; + } + const val = parseFloat(raw); + setPeriodsRaw(raw); + if (val < 0 || val > maxPeriods) { + setPeriodsError(buildPeriodsRangeError(compounding, maxPeriods)); + setPeriodsWarning(""); + } else { + setPeriodsError(""); + setPeriodsWarning( + val === 0 + ? "0 periods means no time passes. Final amount will equal the initial amount." + : "", + ); + } + }; + + const handlePeriodsBlur = (e: React.FocusEvent) => { + const raw = e.target.value; + if (raw === "" || isNaN(parseFloat(raw))) { + setPeriodsRaw(""); + setTimeout( + () => + setPeriodsError("Please enter the number of compounding periods."), + 150, + ); + } + }; + + // Revalidate periods when compounding frequency changes + const handleCompoundingChange = (e: React.ChangeEvent) => { + const freq = e.target.value as CompoundingFrequency; + setCompounding(freq); + if (periodsRaw !== "") { + const newMax = frequencyMap[freq].periods * MAX_YEARS; + const val = parseFloat(periodsRaw); + if (val > newMax) { + setPeriodsError(buildPeriodsRangeError(freq, newMax)); + } else { + setPeriodsError(""); + } + } + }; return (
-

- Interest Calculator -

- {/* Interactive calculator */} +

Interest Calculator

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

I am:

-
-
-
- -
- { - const val = e.target.value; - setAmount(val === "" ? 0 : Math.max(0, parseInt(val) || 0)); - }} - onFocus={(e) => e.target.select()} - className="font-bold 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" - /> -
- - + {amountError || ""} +

-
-
-
- -
- - setInterestRate(Math.max(0, parseFloat(e.target.value) || 0)) - } - className={`font-bold 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 ${ - mode === "borrowing" ? "text-berry" : "text-lagunita" - }`} - /> -
- - + {rateError || rateWarning || ""} +

-
-
-
-
- -
- - setPeriods( - Math.max(0, parseInt(e.target.value) || 0) - ) - } - 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 || periodsWarning || ""} +

+
+ + {/* Compounding */} +
+ +
+ +
+ +
+
-
-
- -
- -
- -
+ Reset +
-
- {/* Results section */} -
-

Results:

-
-
-
-
- Initial amount: -
-
- ${amount.toLocaleString()} + {/* RIGHT: results */} +
+

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

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

+ Try a lower rate or fewer periods. +

+ )} + + {/* Initial amount row */} +
+
+ Initial amount: +
+
+ {hasError ? "-" : formatCurrency(parseFloat(amountRaw) || 0)} +
-
-
+ + {/* Interest row */}
- {mode === "saving" ? "Interest earned" : "Interest paid"}: +
+ {mode === "saving" ? "Interest earned" : "Interest paid"}: +
+
+ {hasError + ? "-" + : resultTooLarge + ? "Too large to display" + : formatCurrency(Math.abs(interestAmount))} +
-
- ${Math.abs(interestAmount).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} + + {/* Final amount row */} +
+
+ Final amount: +
+
+ {hasError + ? "-" + : resultTooLarge + ? "Too large to display" + : formatCurrency(totalAmount)} +
-
-
- Final amount: -
-
- ${totalAmount.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -
-
-
- {/* Example section */} -
- {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. This shows how the balance grows if left unpaid."} +

+
-
{" "} - {/* Wrapper section ends */} +
); -}; - -export default InterestRateVisual; \ No newline at end of file +} diff --git a/app/interactives/mortgage-calculator-v2/page.tsx b/app/interactives/mortgage-calculator-v2/page.tsx index e1cd6c7..b4c6066 100644 --- a/app/interactives/mortgage-calculator-v2/page.tsx +++ b/app/interactives/mortgage-calculator-v2/page.tsx @@ -1,13 +1,63 @@ "use client" -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import ThemeToggle from "@/app/lib/theme-toggle"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/ui/components/tabs"; import { Card, CardContent, CardHeader, CardTitle } from "@/app/ui/components/card"; import InfoPopover from "@/app/ui/components/popover"; +/* ── Limits & ranges (see feedback doc, June 2026) ───────────────────────── */ +const MONTHLY_PAYMENT_MIN = 1; +const MONTHLY_PAYMENT_MAX = 1_000_000; +const HOME_PRICE_MIN = 1; +const HOME_PRICE_MAX = 1_000_000_000; +const DP_DOLLAR_MAX_AFFORD = 1_000_000_000; +const RATE_MIN = 0; +const RATE_MAX = 20; +const DP_PERCENT_MIN = 0; +const DP_PERCENT_MAX = 99.9; +const TAX_RATE_MAX = 10; +const INS_RATE_MAX = 10; +const RELATIVE_CAP = 0.10; // property tax / insurance $ cap = 10% of home price +const HOA_MIN = 0; +const HOA_MAX = 20_000; +const HOME_PRICE_DISPLAY_CEILING = 1_000_000_000_000; // #19 backstop guard + +type Mode = 'afford' | 'payment'; +type Toggle = 'percentage' | 'dollar'; + +const EMPTY_RESULTS: Results = { + homePrice: 0, downPayment: 0, loanAmount: 0, monthlyMortgage: 0, + monthlyTax: 0, monthlyInsurance: 0, totalMonthly: 0, hoaDues: 0, + totalMonthlyHousingCost: 0, +}; + +interface Results { + homePrice: number; + downPayment: number; + loanAmount: number; + monthlyMortgage: number; + monthlyTax: number; + monthlyInsurance: number; + totalMonthly: number; + hoaDues: number; + totalMonthlyHousingCost: number; +} + +function FieldError({ id, children }: { id: string; children: React.ReactNode }) { + return ( + + ); +} + export default function MortgageCalculator() { - const [mode, setMode] = useState('afford'); + const [mode, setMode] = useState('afford'); const [monthlyPayment, setMonthlyPayment] = useState(""); const [homePrice, setHomePrice] = useState(""); const [downPaymentPercent, setDownPaymentPercent] = useState(20); @@ -16,45 +66,153 @@ export default function MortgageCalculator() { const [propertyTaxPercent, setPropertyTaxPercent] = useState(1.25); const [homeInsurancePercent, setHomeInsurancePercent] = useState(0.35); const [hoaDues, setHoaDues] = useState(""); - const [downPaymentMode, setDownPaymentMode] = useState('percentage'); + const [downPaymentMode, setDownPaymentMode] = useState('percentage'); const [downPaymentAmount, setDownPaymentAmount] = useState(0); const [downPaymentPercentInput, setDownPaymentPercentInput] = useState('20'); const [downPaymentAmountInput, setDownPaymentAmountInput] = useState('0'); - const [propertyTaxMode, setPropertyTaxMode] = useState('percentage'); + const [propertyTaxMode, setPropertyTaxMode] = useState('percentage'); const [propertyTaxAmount, setPropertyTaxAmount] = useState(0); - const [homeInsuranceMode, setHomeInsuranceMode] = useState('percentage'); + const [homeInsuranceMode, setHomeInsuranceMode] = useState('percentage'); const [homeInsuranceAmount, setHomeInsuranceAmount] = useState(0); const [calculatedHomePrice, setCalculatedHomePrice] = useState(0); - const [showRateError, setShowRateError] = useState(false); - - const [results, setResults] = useState({ - homePrice: 0, - downPayment: 0, - loanAmount: 0, - monthlyMortgage: 0, - monthlyTax: 0, - monthlyInsurance: 0, - totalMonthly: 0, - hoaDues: 0, - totalMonthlyHousingCost: 0 + const [limitReached, setLimitReached] = useState(false); + + // Required-field "please enter…" messages only surface after the field is + // touched-then-left, per Rachel's note. Range errors surface immediately. + const [touched, setTouched] = useState({ + monthlyPayment: false, + homePrice: false, + interestRate: false, }); + const [results, setResults] = useState(EMPTY_RESULTS); + + const clampPercent = (value: number) => Math.max(0, Math.min(100, value)); + const clampNonNegativeNumber = (value: number) => Math.max(0, Number(value) || 0); + + /* ── Validation ──────────────────────────────────────────────────────── + One source of truth for messages + blocking flags, used by both the calc + and the render so the output can never show stale numbers next to an error. */ + const v = useMemo(() => { + const rateNum = Number(interestRate); + const paymentNum = Number(monthlyPayment); + const priceNum = Number(homePrice); + const hoaNum = Number(hoaDues); + const activePrice = mode === 'afford' ? calculatedHomePrice : priceNum; + + // Interest rate (shared) — 0–20 inclusive + const rateEmpty = interestRate === ""; + const rateRangeBad = !rateEmpty && (rateNum < RATE_MIN || rateNum > RATE_MAX); + let rateMsg = ""; + if (rateEmpty && touched.interestRate) rateMsg = "Please enter an interest rate."; + else if (rateRangeBad) rateMsg = "Please enter an interest rate between 0 and 20%."; + + // Monthly payment (Tab 1) — 1–1,000,000 inclusive, 0 below min + const paymentEmpty = monthlyPayment === ""; + const paymentRangeBad = !paymentEmpty && (paymentNum < MONTHLY_PAYMENT_MIN || paymentNum > MONTHLY_PAYMENT_MAX); + let paymentMsg = ""; + if (paymentEmpty && touched.monthlyPayment) paymentMsg = "Please enter your monthly mortgage payment."; + else if (paymentRangeBad) paymentMsg = "Enter an amount between $1 and $1,000,000. Amounts beyond this are unusual."; + + // Home price (Tab 2) — 1–1,000,000,000 inclusive + const priceEmpty = homePrice === ""; + const priceRangeBad = !priceEmpty && (priceNum < HOME_PRICE_MIN || priceNum > HOME_PRICE_MAX); + let priceMsg = ""; + if (priceEmpty && touched.homePrice) priceMsg = "Please enter the purchase price of the home."; + else if (priceRangeBad) priceMsg = "Enter an amount between $1 and $1,000,000,000."; + + // Down payment + let dpMsg = ""; + let dpBad = false; + if (downPaymentMode === 'percentage') { + dpBad = downPaymentPercent < DP_PERCENT_MIN || downPaymentPercent > DP_PERCENT_MAX; + if (dpBad) dpMsg = "Enter a percentage between 0 - 99.9%."; + } else if (mode === 'afford') { + // Tab 1: absolute ceiling (no home-price input to clamp against) + dpBad = downPaymentAmount < 0 || downPaymentAmount > DP_DOLLAR_MAX_AFFORD; + if (dpBad) dpMsg = "Enter an amount between 0 - 1,000,000,000."; + } else { + // Tab 2: relational against home price + if (downPaymentAmount < 0) { + dpBad = true; + dpMsg = "Down payment cannot be negative. Enter 0 if no down payment is planned."; + } else if (!priceEmpty && priceNum > 0 && downPaymentAmount >= priceNum) { + dpBad = true; + dpMsg = "Your down payment can't exceed the home price. Try lowering it."; + } + } + + // Property taxes + let taxMsg = ""; + let taxBad = false; + if (propertyTaxMode === 'percentage') { + taxBad = propertyTaxPercent < 0 || propertyTaxPercent > TAX_RATE_MAX; + if (taxBad) taxMsg = "Enter a property tax rate between 0 and 10%. Rates beyond this are unusual."; + } else if (activePrice > 0 && propertyTaxAmount > RELATIVE_CAP * activePrice) { + taxBad = true; + taxMsg = "Enter a property tax amount between 0 and 10% of the home price. Amounts beyond this are unusual."; + } + + // Homeowners insurance + let insMsg = ""; + let insBad = false; + if (homeInsuranceMode === 'percentage') { + insBad = homeInsurancePercent < 0 || homeInsurancePercent > INS_RATE_MAX; + if (insBad) insMsg = "Enter a homeowners insurance rate between 0 and 10%. Rates beyond this are unusual."; + } else if (activePrice > 0 && homeInsuranceAmount > RELATIVE_CAP * activePrice) { + insBad = true; + insMsg = "Enter a homeowners insurance amount between 0 and 10% of the home price. Amounts beyond this are unusual."; + } + + // HOA dues — 0–20,000 inclusive + const hoaBad = hoaDues !== "" && (hoaNum < HOA_MIN || hoaNum > HOA_MAX); + const hoaMsg = hoaBad ? "Enter an amount between $0 and $20,000." : ""; + + // Blocking = anything that must stop the calc (includes empties) + const rateBlock = rateEmpty || rateRangeBad; + const paymentBlock = paymentEmpty || paymentRangeBad; + const priceBlock = priceEmpty || priceRangeBad; + + const affordBlocking = paymentBlock || rateBlock || dpBad || taxBad || insBad || hoaBad; + const paymentBlocking = priceBlock || rateBlock || dpBad || taxBad || insBad || hoaBad; + + // Wrong-value = a real bad value is present (drives the coral card). + // Empties alone leave the panel blank instead. + const affordWrongValue = paymentRangeBad || rateRangeBad || dpBad || taxBad || insBad || hoaBad; + const paymentWrongValue = priceRangeBad || rateRangeBad || dpBad || taxBad || insBad || hoaBad; + + return { + rateMsg, paymentMsg, priceMsg, dpMsg, taxMsg, insMsg, hoaMsg, + affordBlocking, paymentBlocking, affordWrongValue, paymentWrongValue, + }; + }, [ + mode, monthlyPayment, homePrice, interestRate, downPaymentMode, + downPaymentPercent, downPaymentAmount, propertyTaxMode, propertyTaxPercent, + propertyTaxAmount, homeInsuranceMode, homeInsurancePercent, + homeInsuranceAmount, hoaDues, calculatedHomePrice, touched, + ]); + const calculateMortgage = useCallback(() => { - const r = (Number(interestRate) / 100 / 12); + const rateNum = Number(interestRate); + const r = rateNum / 100 / 12; const n = loanTerm * 12; const hoaDuesNum = Number(hoaDues) || 0; + const blank = () => { + setResults(EMPTY_RESULTS); + setCalculatedHomePrice(0); + }; + if (mode === 'afford') { - const paymentAmount = Math.max(0, Number(monthlyPayment)); - const interestRateValue = Number(interestRate); + if (v.affordBlocking) { setLimitReached(false); blank(); return; } - if (paymentAmount <= 0 || interestRateValue <= 0) { - setResults({ homePrice: 0, downPayment: 0, loanAmount: 0, monthlyMortgage: 0, monthlyTax: 0, monthlyInsurance: 0, totalMonthly: 0, hoaDues: 0, totalMonthlyHousingCost: 0 }); - setCalculatedHomePrice(0); - return; - } + const paymentAmount = Number(monthlyPayment); + + // 0% interest is now valid (inclusive), so guard the divide-by-zero. + const loanAmount = r === 0 + ? paymentAmount * n + : paymentAmount * ((Math.pow(1 + r, n) - 1) / (r * Math.pow(1 + r, n))); - const loanAmount = paymentAmount * ((Math.pow(1 + r, n) - 1) / (r * Math.pow(1 + r, n))); let computedHomePrice: number; let downPayment: number; @@ -68,15 +226,17 @@ export default function MortgageCalculator() { } } else { const safeDownPaymentPercent = clampPercent(downPaymentPercent); - if (safeDownPaymentPercent >= 100) { - setResults({ homePrice: 0, downPayment: 0, loanAmount: 0, monthlyMortgage: 0, monthlyTax: 0, monthlyInsurance: 0, totalMonthly: 0, hoaDues: 0, totalMonthlyHousingCost: 0 }); - setCalculatedHomePrice(0); - return; - } computedHomePrice = loanAmount / (1 - safeDownPaymentPercent / 100); downPayment = computedHomePrice * (safeDownPaymentPercent / 100); } + // #19 backstop: if the result runs away, show the limit message. + if (!isFinite(computedHomePrice) || computedHomePrice > HOME_PRICE_DISPLAY_CEILING) { + setLimitReached(true); + blank(); + return; + } + setLimitReached(false); setCalculatedHomePrice(computedHomePrice); const monthlyTax = propertyTaxMode === 'percentage' @@ -87,59 +247,63 @@ export default function MortgageCalculator() { ? (computedHomePrice * (homeInsurancePercent / 100)) / 12 : homeInsuranceAmount / 12; - const totalMonthly = Number(monthlyPayment) + monthlyTax + monthlyInsurance + hoaDuesNum; + const totalMonthly = paymentAmount + monthlyTax + monthlyInsurance + hoaDuesNum; setResults({ homePrice: Math.round(computedHomePrice), downPayment: Math.round(downPayment), loanAmount: Math.round(loanAmount), - monthlyMortgage: Number(Math.round(Number(monthlyPayment))), + monthlyMortgage: Math.round(paymentAmount), monthlyTax: Math.round(monthlyTax), monthlyInsurance: Math.round(monthlyInsurance), - totalMonthly: Math.round(Number(totalMonthly)), + totalMonthly: Math.round(totalMonthly), hoaDues: Math.round(hoaDuesNum), - totalMonthlyHousingCost: Math.round(Number(totalMonthly)) + totalMonthlyHousingCost: Math.round(totalMonthly), }); - } else if (mode === 'payment') { - const homePriceAmount = Math.max(0, Number(homePrice)); - const safeDownPaymentPercent = clampPercent(downPaymentPercent); - const downPayment = homePriceAmount * (safeDownPaymentPercent / 100); - const loanAmount = clampNonNegativeNumber(homePriceAmount - downPayment); - - if (homePriceAmount <= 0 || safeDownPaymentPercent >= 100) { - setResults({ homePrice: 0, downPayment: 0, loanAmount: 0, monthlyMortgage: 0, monthlyTax: 0, monthlyInsurance: 0, totalMonthly: 0, hoaDues: 0, totalMonthlyHousingCost: 0 }); - return; - } + } else { + setLimitReached(false); + if (v.paymentBlocking) { blank(); return; } - const monthlyMortgage = loanAmount * (r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1); - if (!isFinite(monthlyMortgage) || monthlyMortgage < loanAmount * r) { - setResults({ homePrice: 0, downPayment: 0, loanAmount: 0, monthlyMortgage: 0, monthlyTax: 0, monthlyInsurance: 0, totalMonthly: 0, hoaDues: 0, totalMonthlyHousingCost: 0 }); - return; - } + const homePriceAmount = Number(homePrice); + const downPayment = downPaymentMode === 'dollar' + ? downPaymentAmount + : homePriceAmount * (clampPercent(downPaymentPercent) / 100); + const loanAmount = Math.max(0, homePriceAmount - downPayment); + + const monthlyMortgage = r === 0 + ? loanAmount / n + : loanAmount * (r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1); + + if (!isFinite(monthlyMortgage)) { blank(); return; } const monthlyTax = propertyTaxMode === 'percentage' - ? (Number(homePrice) * (propertyTaxPercent / 100)) / 12 + ? (homePriceAmount * (propertyTaxPercent / 100)) / 12 : propertyTaxAmount / 12; const monthlyInsurance = homeInsuranceMode === 'percentage' - ? (Number(homePrice) * (homeInsurancePercent / 100)) / 12 + ? (homePriceAmount * (homeInsurancePercent / 100)) / 12 : homeInsuranceAmount / 12; const totalMonthly = monthlyMortgage + monthlyTax + monthlyInsurance + hoaDuesNum; setResults({ - homePrice: Math.round(Number(homePrice)), + homePrice: Math.round(homePriceAmount), downPayment: Math.round(downPayment), loanAmount: Math.round(loanAmount), monthlyMortgage: Math.round(monthlyMortgage), monthlyTax: Math.round(monthlyTax), monthlyInsurance: Math.round(monthlyInsurance), - totalMonthly: Math.round(Number(totalMonthly)), + totalMonthly: Math.round(totalMonthly), hoaDues: Math.round(hoaDuesNum), - totalMonthlyHousingCost: Math.round(Number(totalMonthly)) + totalMonthlyHousingCost: Math.round(totalMonthly), }); } - }, [mode, monthlyPayment, homePrice, downPaymentPercent, downPaymentAmount, downPaymentMode, interestRate, loanTerm, propertyTaxPercent, propertyTaxMode, propertyTaxAmount, homeInsurancePercent, homeInsuranceMode, homeInsuranceAmount, hoaDues]); + }, [ + mode, monthlyPayment, homePrice, downPaymentPercent, downPaymentAmount, + downPaymentMode, interestRate, loanTerm, propertyTaxPercent, propertyTaxMode, + propertyTaxAmount, homeInsurancePercent, homeInsuranceMode, + homeInsuranceAmount, hoaDues, v, + ]); useEffect(() => { calculateMortgage(); @@ -155,28 +319,13 @@ export default function MortgageCalculator() { }).format(value); }; - const clampPercent = (value: number) => Math.max(0, Math.min(100, value)); - const clampNonNegativeNumber = (value: number) => Math.max(0, Number(value) || 0); - const clampDownPaymentAmount = (amount: number, price: number) => Math.max(0, Math.min(amount, price)); - - const emptyResultsString = "Enter values to see your estimate" - const validationErrorMessage = "Your inputs are not valid for this scenario. Try lowering the down payment or increasing the home price." - const hasValidationError = ( - Number(monthlyPayment) < 0 || - Number(homePrice) < 0 || - Number(interestRate) < 0 || - Number(propertyTaxPercent) < 0 || - Number(homeInsurancePercent) < 0 || - Number(propertyTaxAmount) < 0 || - Number(homeInsuranceAmount) < 0 || - Number(hoaDues) < 0 || - downPaymentPercent < 0 || - downPaymentPercent >= 100 || - downPaymentAmount < 0 || - (Number(homePrice) > 0 && downPaymentAmount > Number(homePrice)) - ); - const showAffordResults = mode === 'afford' && Number(monthlyPayment) > 0 && Number(interestRate) > 0 && downPaymentPercent < 100; - const showPaymentResults = mode === 'payment' && Number(homePrice) > 0 && interestRate !== "" && downPaymentPercent < 100; + const showAffordResults = mode === 'afford' && !v.affordBlocking && !limitReached; + const showPaymentResults = mode === 'payment' && !v.paymentBlocking; + const affordCoral = mode === 'afford' && (v.affordWrongValue || limitReached); + const paymentCoral = mode === 'payment' && v.paymentWrongValue; + const emptyResultsString = "Enter values to see your estimate."; + const fixFieldsString = "Please fix the highlighted fields to see your estimate."; + const limitString = "That payment is too high to calculate. Try a lower amount to see your estimate."; const handleReset = () => { setMonthlyPayment(''); @@ -196,9 +345,95 @@ export default function MortgageCalculator() { setHoaDues(''); setDownPaymentMode('percentage'); setCalculatedHomePrice(0); - setShowRateError(false); - setResults({ homePrice: 0, downPayment: 0, loanAmount: 0, monthlyMortgage: 0, monthlyTax: 0, monthlyInsurance: 0, totalMonthly: 0, hoaDues: 0, totalMonthlyHousingCost: 0 }); - } + setLimitReached(false); + setTouched({ monthlyPayment: false, homePrice: false, interestRate: false }); + setResults(EMPTY_RESULTS); + }; + + const CoralCard = ({ message }: { message: string }) => ( +
+

{message}

+
+ ); + + const EmptyPanel = () => ( +
+

{emptyResultsString}

+
+ ); + + const ResultsBody = ({ headline }: { headline: string }) => ( +
+
+
+
+ Down payment: +
+
+ {formatCurrency(results.downPayment || 0)} +
+
+
+
+ Loan amount: +
+
+ {formatCurrency(results.loanAmount || 0)} +
+
+
+

Monthly breakdown

+
+
+
+
+ Mortgage payment: +
+
+ {formatCurrency(results.monthlyMortgage || 0)} +
+
+
+
+ Property taxes: +
+
+ {formatCurrency(results.monthlyTax || 0)} +
+
+
+
+ Insurance: +
+
+ {formatCurrency(results.monthlyInsurance || 0)} +
+
+
+
+ HOA: +
+
+ {formatCurrency(results.hoaDues || 0)} +
+
+
+
+ Total monthly housing cost: +
+
+ {formatCurrency(results.totalMonthlyHousingCost || 0)} +
+
+
+

{headline}

+
+
+
+ ); return (
@@ -207,19 +442,9 @@ export default function MortgageCalculator() {

Mortgage Calculator Suite

- {/* Fix 6: role="alert" so screen readers announce the error */} - {hasValidationError && ( -
- {validationErrorMessage} -
- )} - { setMode(v); setShowRateError(false); }} + onValueChange={(value) => setMode(value as Mode)} className="w-full" > @@ -242,7 +467,6 @@ export default function MortgageCalculator() { {/* Monthly payment */}
- {/* Fix 1: htmlFor links label to input */}
- {/* Fix 2: aria-hidden on decorative symbols */}
-

- This is the amount allocated to the loan payment - (principal + interest). Taxes, insurance, and HOA are - added separately below. -

+ {v.paymentMsg ? ( + {v.paymentMsg} + ) : ( +

+ This is the amount allocated to the loan payment + (principal + interest). Taxes, insurance, and HOA are + added separately below. +

+ )}
{/* Down Payment */}
- - Down payment - - {/* Fix 4: fieldset+legend for radio group */} + Down payment
Down payment type
@@ -305,24 +528,15 @@ export default function MortgageCalculator() { checked={downPaymentMode === "percentage"} onChange={() => { setDownPaymentMode("percentage"); - if ( - downPaymentAmount >= 0 && - calculatedHomePrice > 0 - ) { - const percentValue = - (downPaymentAmount / calculatedHomePrice) * - 100; + if (downPaymentAmount >= 0 && calculatedHomePrice > 0) { + const percentValue = (downPaymentAmount / calculatedHomePrice) * 100; setDownPaymentPercent(percentValue); - setDownPaymentPercentInput( - percentValue.toFixed(2), - ); + setDownPaymentPercentInput(percentValue.toFixed(2)); } }} className="w-4 h-4 accent-lagunita cursor-pointer" /> - + Percent @@ -333,21 +547,13 @@ export default function MortgageCalculator() { checked={downPaymentMode === "dollar"} onChange={() => { setDownPaymentMode("dollar"); - const amountValue = - calculatedHomePrice * - (downPaymentPercent / 100); + const amountValue = calculatedHomePrice * (downPaymentPercent / 100); setDownPaymentAmount(amountValue); - setDownPaymentAmountInput( - calculatedHomePrice > 0 - ? Math.round(amountValue).toString() - : "", - ); + setDownPaymentAmountInput(calculatedHomePrice > 0 ? Math.round(amountValue).toString() : ""); }} className="w-4 h-4 accent-lagunita cursor-pointer" /> - + Dollars @@ -372,24 +578,19 @@ export default function MortgageCalculator() { setDownPaymentAmountInput("0"); return; } - const value = clampPercent(Number(raw)); + const value = Number(raw); setDownPaymentPercent(value); const price = calculatedHomePrice || 0; - const computedAmount = (value / 100) * price; + const computedAmount = (clampPercent(value) / 100) * price; setDownPaymentAmount(computedAmount); - setDownPaymentAmountInput( - Math.round(computedAmount).toString(), - ); + setDownPaymentAmountInput(Math.round(computedAmount).toString()); }} aria-label="Down payment percentage" - className="w-full pl-4 pr-16 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + aria-describedby={v.dpMsg ? "down-payment-afford-error" : undefined} + aria-invalid={!!v.dpMsg} + className={`w-full pl-4 pr-16 py-3 border-2 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${v.dpMsg ? "border-[var(--color-inline-error)]" : "border-gray-300"}`} /> - +
) : (
@@ -397,13 +598,7 @@ export default function MortgageCalculator() { id="down-payment-amount-afford" type="text" inputMode="numeric" - value={ - downPaymentAmountInput - ? Number(downPaymentAmountInput).toLocaleString( - "en-US", - ) - : "" - } + value={downPaymentAmountInput ? Number(downPaymentAmountInput).toLocaleString("en-US") : ""} onChange={(e) => { const raw = e.target.value.replace(/,/g, ""); if (raw === "" || /^\d*$/.test(raw)) { @@ -418,115 +613,57 @@ export default function MortgageCalculator() { } }} aria-label="Down payment amount in dollars" - className="w-full pl-8 pr-16 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition" + aria-describedby={v.dpMsg ? "down-payment-afford-error" : undefined} + aria-invalid={!!v.dpMsg} + className={`w-full pl-8 pr-16 py-3 border-2 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition ${v.dpMsg ? "border-[var(--color-inline-error)]" : "border-gray-300"}`} /> - +
)} -

- Enter 0 if no down payment is planned. -

+ {v.dpMsg ? ( + {v.dpMsg} + ) : ( +

Enter 0 if no down payment is planned.

+ )}
{/* Interest Rate */}
-
- {/* Loan Term — Fix 4: fieldset+legend */} + {/* Loan Term */}
- - Loan term - + Loan term
@@ -534,21 +671,14 @@ export default function MortgageCalculator() { {/* Optional Section */}
- {/* Fix 7: proper h2 heading */} -

- Additional housing costs -

+

Additional housing costs

{/* Property Taxes */}
- - Property taxes (annual) - + Property taxes (annual)
- - Property tax input type - + Property tax input type
@@ -612,25 +723,16 @@ export default function MortgageCalculator() { step="0.01" value={propertyTaxPercent || ""} onChange={(e) => { - const value = clampNonNegativeNumber( - e.target.value === "" - ? 0 - : Number(e.target.value), - ); + const value = clampNonNegativeNumber(e.target.value === "" ? 0 : Number(e.target.value)); setPropertyTaxPercent(value); - setPropertyTaxAmount( - (value / 100) * (calculatedHomePrice || 0), - ); + setPropertyTaxAmount((value / 100) * (calculatedHomePrice || 0)); }} aria-label="Property tax percentage" - className="w-full pl-4 pr-16 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + aria-describedby={v.taxMsg ? "property-tax-afford-error" : undefined} + aria-invalid={!!v.taxMsg} + className={`w-full pl-4 pr-16 py-3 border-2 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${v.taxMsg ? "border-[var(--color-inline-error)]" : "border-gray-300"}`} /> - +
) : (
@@ -638,49 +740,33 @@ export default function MortgageCalculator() { id="property-tax-amount-afford" type="text" inputMode="numeric" - value={ - propertyTaxAmount - ? Math.round(propertyTaxAmount).toLocaleString( - "en-US", - ) - : "" - } + value={propertyTaxAmount ? Math.round(propertyTaxAmount).toLocaleString("en-US") : ""} onChange={(e) => { const raw = e.target.value.replace(/,/g, ""); if (raw === "" || /^\d*$/.test(raw)) { - const value = - raw === "" - ? 0 - : clampNonNegativeNumber(Number(raw)); + const value = raw === "" ? 0 : clampNonNegativeNumber(Number(raw)); setPropertyTaxAmount(value); const price = calculatedHomePrice || 0; - if (price > 0) - setPropertyTaxPercent((value / price) * 100); + if (price > 0) setPropertyTaxPercent((value / price) * 100); } }} aria-label="Property tax annual dollar amount" - className="w-full pl-8 pr-16 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition" + aria-describedby={v.taxMsg ? "property-tax-afford-error" : undefined} + aria-invalid={!!v.taxMsg} + className={`w-full pl-8 pr-16 py-3 border-2 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition ${v.taxMsg ? "border-[var(--color-inline-error)]" : "border-gray-300"}`} /> - +
)} + {v.taxMsg && {v.taxMsg}}
{/* Homeowners Insurance */}
- - Homeowners insurance (annual) - + Homeowners insurance (annual)
- - Home insurance input type - + Home insurance input type
@@ -744,25 +811,16 @@ export default function MortgageCalculator() { step="0.01" value={homeInsurancePercent || ""} onChange={(e) => { - const value = clampNonNegativeNumber( - e.target.value === "" - ? 0 - : Number(e.target.value), - ); + const value = clampNonNegativeNumber(e.target.value === "" ? 0 : Number(e.target.value)); setHomeInsurancePercent(value); - setHomeInsuranceAmount( - (value / 100) * (calculatedHomePrice || 0), - ); + setHomeInsuranceAmount((value / 100) * (calculatedHomePrice || 0)); }} aria-label="Home insurance percentage" - className="w-full pl-4 pr-16 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + aria-describedby={v.insMsg ? "home-insurance-afford-error" : undefined} + aria-invalid={!!v.insMsg} + className={`w-full pl-4 pr-16 py-3 border-2 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${v.insMsg ? "border-[var(--color-inline-error)]" : "border-gray-300"}`} /> - +
) : (
@@ -770,199 +828,72 @@ export default function MortgageCalculator() { id="home-insurance-amount-afford" type="text" inputMode="numeric" - value={ - homeInsuranceAmount - ? Math.round( - homeInsuranceAmount, - ).toLocaleString("en-US") - : "" - } + value={homeInsuranceAmount ? Math.round(homeInsuranceAmount).toLocaleString("en-US") : ""} onChange={(e) => { const raw = e.target.value.replace(/,/g, ""); if (raw === "" || /^\d*$/.test(raw)) { - const value = - raw === "" - ? 0 - : clampNonNegativeNumber(Number(raw)); + const value = raw === "" ? 0 : clampNonNegativeNumber(Number(raw)); setHomeInsuranceAmount(value); const price = calculatedHomePrice || 0; - if (price > 0) - setHomeInsurancePercent( - (value / price) * 100, - ); + if (price > 0) setHomeInsurancePercent((value / price) * 100); } }} aria-label="Home insurance annual dollar amount" - className="w-full pl-8 pr-16 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition" + aria-describedby={v.insMsg ? "home-insurance-afford-error" : undefined} + aria-invalid={!!v.insMsg} + className={`w-full pl-8 pr-16 py-3 border-2 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition ${v.insMsg ? "border-[var(--color-inline-error)]" : "border-gray-300"}`} /> - +
)} + {v.insMsg && {v.insMsg}}
{/* HOA Dues */}
- +
- + { const raw = e.target.value.replace(/,/g, ""); - if (raw === "") { - setHoaDues(""); - return; - } - const clamped = Math.min( - Math.max(0, Number(raw)), - 100000, - ); - setHoaDues(clamped.toString()); + if (raw === "" || /^\d*$/.test(raw)) setHoaDues(raw); }} - placeholder="" - step="1" - min="0" - max="100000" - className="w-full pl-8 pr-4 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + aria-describedby={v.hoaMsg ? "hoa-dues-afford-error" : undefined} + aria-invalid={!!v.hoaMsg} + className={`w-full pl-8 pr-4 py-3 border-2 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${v.hoaMsg ? "border-[var(--color-inline-error)]" : "border-gray-300"}`} />
+ {v.hoaMsg && {v.hoaMsg}}
- {/* Right Column - Results — Fix 3: aria-live */} + {/* Right Column - Results */}
- + {showAffordResults ? ( <> - {/* Fix 7: explicit h2 */} - - Estimated home price - + Estimated home price
-

0 - ? "text-3xl font-bold text-[var(--color-teal)]" - : "text-lg font-bold text-gray-500 italic" - } - > - {results.homePrice > 0 - ? formatCurrency(Number(results.homePrice)) - : emptyResultsString} +

+ {formatCurrency(Number(results.homePrice))}

-
-
-
-
- Down payment: -
-
- {formatCurrency(results.downPayment || 0)} -
-
-
-
- Loan amount: -
-
- {formatCurrency(results.loanAmount || 0)} -
-
-
-

- Monthly breakdown -

-
-
-
-
- Mortgage payment: -
-
- {formatCurrency(results.monthlyMortgage || 0)} -
-
-
-
- Property taxes: -
-
- {formatCurrency(results.monthlyTax || 0)} -
-
-
-
- Insurance: -
-
- {formatCurrency( - results.monthlyInsurance || 0, - )} -
-
-
-
- HOA: -
-
- {formatCurrency(results.hoaDues || 0)} -
-
-
-
- Total monthly housing cost: -
-
- {formatCurrency( - results.totalMonthlyHousingCost || 0, - )} -
-
-
-

- This estimate is based on the portion of your - monthly budget allocated to principal and - interest. Taxes, insurance, and HOA are shown - separately. -

-
-
-
+
+ ) : affordCoral ? ( + ) : ( -
+ )}
@@ -972,82 +903,53 @@ export default function MortgageCalculator() { {/* ── Tab 2: Payment ────────────────────────────────────────── */}

- Estimate a monthly mortgage payment based on your target home - price. + Estimate a monthly mortgage payment based on your target home price.

{/* Home Price */}
- +
- + { const raw = e.target.value.replace(/,/g, ""); if (raw === "" || /^\d*$/.test(raw)) { setHomePrice(raw); - const priceValue = Math.max(0, Number(raw)); + const priceValue = Number(raw) || 0; if (downPaymentMode === "dollar") { - const clampedDownPayment = clampDownPaymentAmount( - downPaymentAmount, - priceValue, - ); - setDownPaymentAmount(clampedDownPayment); - setDownPaymentAmountInput( - Math.round(clampedDownPayment).toString(), - ); - const percentValue = - priceValue > 0 - ? (clampedDownPayment / priceValue) * 100 - : 0; + const percentValue = priceValue > 0 ? (downPaymentAmount / priceValue) * 100 : 0; setDownPaymentPercent(percentValue); - setDownPaymentPercentInput( - percentValue.toFixed(2), - ); + setDownPaymentPercentInput(percentValue.toFixed(2)); } else { - const computedAmount = - (downPaymentPercent / 100) * priceValue; + const computedAmount = (downPaymentPercent / 100) * priceValue; setDownPaymentAmount(computedAmount); - setDownPaymentAmountInput( - Math.round(computedAmount).toString(), - ); + setDownPaymentAmountInput(Math.round(computedAmount).toString()); } } }} - className="w-full pl-8 pr-4 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition" + onBlur={() => setTouched((t) => ({ ...t, homePrice: true }))} + aria-describedby={v.priceMsg ? "home-price-error" : undefined} + aria-invalid={!!v.priceMsg} + className={`w-full pl-8 pr-4 py-3 border-2 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition ${v.priceMsg ? "border-[var(--color-inline-error)]" : "border-gray-300"}`} />
-

- Enter the purchase price of the home. -

+ {v.priceMsg ? ( + {v.priceMsg} + ) : ( +

Enter the purchase price of the home.

+ )}
{/* Down Payment */}
- - Down payment - + Down payment
Down payment type
@@ -1058,26 +960,15 @@ export default function MortgageCalculator() { checked={downPaymentMode === "percentage"} onChange={() => { setDownPaymentMode("percentage"); - if ( - downPaymentAmount >= 0 && - Number(homePrice) > 0 - ) { - const percentValue = - (downPaymentAmount / Number(homePrice)) * - 100; + if (downPaymentAmount >= 0 && Number(homePrice) > 0) { + const percentValue = (downPaymentAmount / Number(homePrice)) * 100; setDownPaymentPercent(percentValue); - setDownPaymentPercentInput( - percentValue.toFixed(2), - ); + setDownPaymentPercentInput(percentValue.toFixed(2)); } }} className="w-4 h-4 accent-lagunita cursor-pointer" /> - - Percent - + Percent
@@ -1125,24 +1006,19 @@ export default function MortgageCalculator() { setDownPaymentAmountInput("0"); return; } - const value = clampPercent(Number(raw)); + const value = Number(raw); setDownPaymentPercent(value); const price = Number(homePrice) || 0; - const computedAmount = (value / 100) * price; + const computedAmount = (clampPercent(value) / 100) * price; setDownPaymentAmount(computedAmount); - setDownPaymentAmountInput( - Math.round(computedAmount).toString(), - ); + setDownPaymentAmountInput(Math.round(computedAmount).toString()); }} aria-label="Down payment percentage" - className="w-full pl-4 pr-16 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + aria-describedby={v.dpMsg ? "down-payment-payment-error" : undefined} + aria-invalid={!!v.dpMsg} + className={`w-full pl-4 pr-16 py-3 border-2 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${v.dpMsg ? "border-[var(--color-inline-error)]" : "border-gray-300"}`} /> - +
) : (
@@ -1150,13 +1026,7 @@ export default function MortgageCalculator() { id="down-payment-amount-payment" type="text" inputMode="numeric" - value={ - downPaymentAmountInput - ? Number(downPaymentAmountInput).toLocaleString( - "en-US", - ) - : "" - } + value={downPaymentAmountInput ? Number(downPaymentAmountInput).toLocaleString("en-US") : ""} onChange={(e) => { const raw = e.target.value.replace(/,/g, ""); if (raw === "" || /^\d*$/.test(raw)) { @@ -1167,121 +1037,65 @@ export default function MortgageCalculator() { setDownPaymentPercentInput("0"); return; } - const maxPrice = Number(homePrice) || 0; - const clampedAmount = clampDownPaymentAmount( - Number(raw), - maxPrice, - ); - setDownPaymentAmount(clampedAmount); - const percentValue = - maxPrice > 0 - ? (clampedAmount / maxPrice) * 100 - : 0; + // No clamp: an amount >= home price must surface as an error (#24). + const amount = Number(raw); + setDownPaymentAmount(amount); + const price = Number(homePrice) || 0; + const percentValue = price > 0 ? (amount / price) * 100 : 0; setDownPaymentPercent(percentValue); - setDownPaymentPercentInput( - percentValue.toFixed(2), - ); + setDownPaymentPercentInput(percentValue.toFixed(2)); } }} aria-label="Down payment amount in dollars" - className="w-full pl-8 pr-16 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition" + aria-describedby={v.dpMsg ? "down-payment-payment-error" : undefined} + aria-invalid={!!v.dpMsg} + className={`w-full pl-8 pr-16 py-3 border-2 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition ${v.dpMsg ? "border-[var(--color-inline-error)]" : "border-gray-300"}`} /> - +
)} -

- Enter 0 if no down payment is planned. -

+ {v.dpMsg ? ( + {v.dpMsg} + ) : ( +

Enter 0 if no down payment is planned.

+ )}
{/* Interest Rate */}
- +
{ - const raw = e.target.value; - if (raw === '') { setInterestRate(''); return; } - setInterestRate(Math.max(0, Number(raw)).toString()); - }} - onBlur={() => { - if (interestRate === '' && homePrice !== '') { - setShowRateError(true); - } else { - setShowRateError(false); - } - }} - aria-describedby={showRateError ? "interest-rate-payment-error" : undefined} - aria-invalid={showRateError} - className={`w-full pr-8 pl-4 py-3 border-2 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${showRateError ? "border-[var(--color-inline-error)] border-2" : ""}`} + onChange={(e) => setInterestRate(e.target.value)} + onBlur={() => setTouched((t) => ({ ...t, interestRate: true }))} + aria-describedby={v.rateMsg ? "interest-rate-payment-error" : undefined} + aria-invalid={!!v.rateMsg} + className={`w-full pr-8 pl-4 py-3 border-2 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${v.rateMsg ? "border-[var(--color-inline-error)]" : "border-gray-300"}`} /> - +
- {showRateError && ( - - )} + {v.rateMsg && {v.rateMsg}}
- {/* Loan Term — Fix 5: separate name from afford tab */} + {/* Loan Term */}
- - Loan term - + Loan term
@@ -1289,20 +1103,14 @@ export default function MortgageCalculator() { {/* Optional Section */}
-

- Additional housing costs -

+

Additional housing costs

{/* Property Taxes */}
- - Property taxes (annual) - + Property taxes (annual)
- - Property tax input type - + Property tax input type
+ {v.taxMsg && {v.taxMsg}}
{/* Homeowners Insurance */}
- - Homeowners insurance (annual) - + Homeowners insurance (annual)
- - Home insurance input type - + Home insurance input type
+ {v.insMsg && {v.insMsg}}
{/* HOA Dues */}
- +
- + { const raw = e.target.value.replace(/,/g, ""); - if (raw === "") { - setHoaDues(""); - return; - } - setHoaDues(Math.max(0, Number(raw)).toString()); + if (raw === "" || /^\d*$/.test(raw)) setHoaDues(raw); }} - placeholder="" - step="1" - min="0" - className="w-full pl-8 pr-4 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + aria-describedby={v.hoaMsg ? "hoa-dues-payment-error" : undefined} + aria-invalid={!!v.hoaMsg} + className={`w-full pl-8 pr-4 py-3 border-2 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${v.hoaMsg ? "border-[var(--color-inline-error)]" : "border-gray-300"}`} />
+ {v.hoaMsg && {v.hoaMsg}}
- {/* Right Column - Results — Fix 3: aria-live */} + {/* Right Column - Results */}
- + {showPaymentResults ? ( <> - - Estimated monthly mortgage payment - + Estimated monthly mortgage payment
-

0 - ? "text-3xl font-bold text-[var(--color-teal)]" - : "text-lg font-bold text-gray-500 italic" - } - > - {results.homePrice > 0 - ? formatCurrency( - Number(results.monthlyMortgage), - ) - : emptyResultsString} +

+ {formatCurrency(Number(results.monthlyMortgage))}

-
-
-
-
- Down payment: -
-
- {formatCurrency(results.downPayment || 0)} -
-
-
-
- Loan amount: -
-
- {formatCurrency(results.loanAmount)} -
-
-
-

- Monthly breakdown -

-
-
-
-
- Mortgage payment: -
-
- {formatCurrency(results.monthlyMortgage)} -
-
-
-
- Property taxes: -
-
- {formatCurrency(results.monthlyTax)} -
-
-
-
- Insurance: -
-
- {formatCurrency(results.monthlyInsurance)} -
-
-
-
- HOA: -
-
- {formatCurrency(results.hoaDues)} -
-
-
-
- Total monthly housing cost: -
-
- {formatCurrency( - results.totalMonthlyHousingCost, - )} -
-
-
-

- This estimate shows your monthly mortgage - payment based on the loan amount, interest - rate, and term. Taxes, insurance, and HOA are - shown separately. -

-
-
-
+
+ ) : paymentCoral ? ( + ) : ( -
+ )}
- {/* Fix 8: type="button" to prevent accidental form submit */}
- {showHowToUse && ( -
-
-

Cash Flow Signs

-

This calculator uses signs to show the direction of money:

-
-
Positive — money you receive (cash in)
-
Negative — money you pay (cash out)
-
-
-
- Example — I am: -
- {(["saving", "borrowing"] as const).map((mode, index) => ( - - ))} -
-
- {currentExample && ( -
-

{currentExample.title}

-
    - {currentExample.bullets.map((bullet, idx) =>
  • {bullet}
  • )} -
- -
- )} -
-
-
- )} -
- ) - } - - return ( -
-
- {/* Header */} -

Time Value of Money Calculator

- - - {/* Solve-for tabs */} -
-

Solve for

-
- {SOLVE_OPTIONS.map((option) => ( - - ))} -
-
- - {/* How to use */} - - -
- {/* Input Fields */} -
- {/* Present Value */} - {solveFor !== "PV" && ( -
- -
- - $ - - - handleInputChange(e.target.value, setPresentValue) - } - onBlur={(e) => - handleInputBlur(e.target.value, setPresentValue) - } - className={`border-border pl-7 bg-card ${getFieldError("presentValue") ? "border-2 border-[var(--color-inline-error)]" : ""}`} - /> -
- {getFieldError("presentValue") && ( -

- {getFieldError("presentValue")} -

- )} - {presentValue && ( -

- {SIGN_HELPER} -

- )} -
- )} - - {/* Payment (early position for RATE/NPER) */} - {(solveFor === "RATE" || solveFor === "NPER") && ( -
- -
- - $ - - - handleInputChange(e.target.value, setPayment) - } - onBlur={(e) => handleInputBlur(e.target.value, setPayment)} - className={`border-border pl-7 bg-card ${getFieldError("payment") ? "border-2 border-[var(--color-inline-error)]" : ""}`} - /> -
- {getFieldError("payment") && ( -

- {getFieldError("payment")} -

- )} - {payment && ( -

- {SIGN_HELPER} -

- )} -
- )} - - {/* Future Value */} - {solveFor !== "FV" && ( -
- -
- - $ - - - handleInputChange(e.target.value, setFutureValue) - } - onBlur={(e) => - handleInputBlur(e.target.value, setFutureValue) - } - className={`border-border pl-7 bg-card ${getFieldError("futureValue") ? "border-2 border-[var(--color-inline-error)]" : ""}`} - /> -
- {getFieldError("futureValue") && ( -

- {getFieldError("futureValue")} -

- )} - {futureValue && ( -

- {SIGN_HELPER} -

- )} -
- )} - - {/* Payment (normal position) */} - {solveFor !== "PMT" && - solveFor !== "RATE" && - solveFor !== "NPER" && ( -
- -
- - $ - - - handleInputChange(e.target.value, setPayment) - } - onBlur={(e) => - handleInputBlur(e.target.value, setPayment) - } - className={`border-border pl-7 bg-card ${getFieldError("payment") ? "border-2 border-[var(--color-inline-error)]" : ""}`} - /> -
- {getFieldError("payment") && ( -

- {getFieldError("payment")} -

- )} -
- )} - - {/* Annual Rate */} - {solveFor !== "RATE" && ( -
- -
- - handleInputChange(e.target.value, setAnnualRate) - } - onBlur={(e) => - handleInputBlur(e.target.value, setAnnualRate) - } - className={`border-border pr-8 bg-card ${getFieldError("annualRate") ? "border-2 border-[var(--color-inline-error)]" : ""}`} - /> - - % - -
- {getFieldError("annualRate") && ( -

- {getFieldError("annualRate")} -

- )} -
- )} - - {/* Number of Periods */} - {solveFor !== "NPER" && ( -
- - { - const val = e.target.value; - if (val === "" || /^\d*$/.test(val)) setPeriods(val); - }} - className={`border-border bg-card ${getFieldError("periods") ? "border-2 border-[var(--color-inline-error)]" : ""}`} - /> - {getFieldError("periods") && ( -

- {getFieldError("periods")} -

- )} -
- )} - - {/* Compounding Frequency */} -
- - -
- - {/* Payment Frequency */} -
- -
- {(["same", "different"] as const).map((mode) => ( - - ))} -
- {paymentFrequencyMode === "different" && ( -
- - -
- )} -
- - {/* Payment Timing */} -
- -
- {(["end", "beginning"] as const).map((timing, i) => ( - - ))} -
-

- {paymentTiming === "end" - ? "Payment made at the end of the period" - : "Payment made at the beginning of the period"} -

-
- -
- -
-
- - {/* Results Card */} - - -

- {currentOption?.label} -

- {displayError ? ( -

- {displayError} -

- ) : result !== null ? ( -

- {formatResult(result)} -

- ) : ( -

- )} -
-
-
- - {/* Mobile sticky footer */} -
-
-
{currentOption?.label}
- {displayError ? ( -

- {displayError} -

- ) : result !== null ? ( -

- {formatResult(result)} -

- ) : ( -

Enter values above

- )} - -
-
-
-
- ); -} - -export default TVMCalculator diff --git a/app/interactives/time-value-money-calculator/page.tsx b/app/interactives/time-value-money-calculator/page.tsx index 13f183a..46dfc13 100644 --- a/app/interactives/time-value-money-calculator/page.tsx +++ b/app/interactives/time-value-money-calculator/page.tsx @@ -1,14 +1,1160 @@ "use client" - import { TVMCalculator } from "./app" - - export default function Page() { - return ( -
+ +import { useState, useCallback, useEffect, useMemo } from "react" +import { Card, CardContent } from "@/app/ui/components/card" +import { Input } from "@/app/ui/components/input" +import { Label } from "@/app/ui/components/label" +import { Button } from "@/app/ui/components/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/app/ui/components/select" +import { Info, ChevronDown, ChevronUp } from "lucide-react" +import ThemeToggle from "@/app/lib/theme-toggle" +import { FaPlus, FaMinus } from "react-icons/fa6" +import { Tabs, TabsList, TabsTrigger } from "@/app/ui/components/tabs" + +type SolveFor = "FV" | "PV" | "PMT" | "RATE" | "NPER" +type PaymentTiming = "end" | "beginning" +type PaymentFrequencyMode = "same" | "different" + +const FREQUENCY_OPTIONS = [ + { value: "1", label: "Annually", perYear: 1, singular: "year" }, + { value: "2", label: "Semi-annually", perYear: 2, singular: "semi-annual period" }, + { value: "4", label: "Quarterly", perYear: 4, singular: "quarter" }, + { value: "12", label: "Monthly", perYear: 12, singular: "month" }, + { value: "26", label: "Bi-weekly", perYear: 26, singular: "bi-weekly period" }, + { value: "52", label: "Weekly", perYear: 52, singular: "week" }, + { value: "365", label: "Daily", perYear: 365, singular: "day" }, +] + +const SOLVE_OPTIONS: { value: SolveFor; label: string; description: string }[] = [ + { value: "FV", label: "Future value", description: "Calculate the value at the end of the time period." }, + { value: "PV", label: "Present value", description: "Calculate the current value needed." }, + { value: "PMT", label: "Payment", description: "Calculate the periodic payment amount." }, + { value: "RATE", label: "Interest rate", description: "Calculate the annual interest rate." }, + { value: "NPER", label: "Periods", description: "Calculate the number of periods required." }, +] + +// Spec-correct constraints: +// - Payment: -$100,000,000 to $100,000,000 +// - Rate: -99.99% to 1,000% +// - Periods: dynamic — 500 years × effectivePaymentFrequency (computed at validation time) +const CONSTRAINTS = { + presentValue: { min: -1_000_000_000, max: 1_000_000_000 }, + futureValue: { min: -1_000_000_000, max: 1_000_000_000 }, + payment: { min: -100_000_000, max: 100_000_000 }, + annualRate: { min: -99.99, max: 1_000 }, +} + +// Ceiling above which a solved output is "Too large to display" +const OUTPUT_OVERFLOW_CEILING = 1e15 + +interface FieldError { + field: string + message: string +} + +export default function Page() { + const [presentValue, setPresentValue] = useState("") + const [futureValue, setFutureValue] = useState("") + const [payment, setPayment] = useState("") + const [annualRate, setAnnualRate] = useState("") + const [periods, setPeriods] = useState("") + const [compoundingFrequency, setCompoundingFrequency] = useState("1") + const [paymentFrequencyMode, setPaymentFrequencyMode] = useState("same") + const [paymentFrequency, setPaymentFrequency] = useState("1") + const [paymentTiming, setPaymentTiming] = useState("end") + const [solveFor, setSolveFor] = useState("FV") + const [result, setResult] = useState(null) + const [resultOverflow, setResultOverflow] = useState(false) + const [calcError, setCalcError] = useState("") + const [fieldErrors, setFieldErrors] = useState([]) + const [signError, setSignError] = useState("") + const [showHowToUse, setShowHowToUse] = useState(false) + const [exampleMode, setExampleMode] = useState<"saving" | "borrowing">("saving") + + // ── Helpers ──────────────────────────────────────────────────────────────── + + const formatWithCommas = (value: string): string => { + const rawValue = value.replace(/,/g, "") + if (rawValue === "" || rawValue === "-" || rawValue === "." || rawValue === "-.") return rawValue + const parts = rawValue.split(".") + const isNeg = parts[0].startsWith("-") + const absInt = isNeg ? parts[0].slice(1) : parts[0] + const formatted = absInt.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + const signed = isNeg ? "-" + formatted : formatted + return parts.length > 1 ? signed + "." + parts[1] : signed + } + + const handleInputChange = (value: string, setter: (val: string) => void) => { + const raw = value.replace(/,/g, "") + if (raw === "" || raw === "-" || raw === "." || raw === "-.") { setter(raw); return } + if (/^-?\d*\.?\d*$/.test(raw)) setter(formatWithCommas(raw)) + } + + const handleInputBlur = (value: string, setter: (val: string) => void) => { + const raw = value.replace(/,/g, "") + if (raw.startsWith(".")) setter(formatWithCommas("0" + raw)) + else if (raw.startsWith("-.")) setter(formatWithCommas("-0" + raw.slice(1))) + } + + const effectivePaymentFrequency = useMemo(() => + paymentFrequencyMode === "same" ? compoundingFrequency : paymentFrequency, + [paymentFrequencyMode, compoundingFrequency, paymentFrequency] + ) + + const isActivelyCalculating = useMemo(() => { + const requiredFilled: Record = { + FV: [annualRate, periods], + PV: [annualRate, periods], + PMT: [annualRate, periods], + RATE: [periods], + NPER: [annualRate], + }; + return requiredFilled[solveFor].every((f) => f.trim() !== ""); + }, [solveFor, annualRate, periods]); + + const getPeriodUnitLabel = useCallback((): string => { + switch (effectivePaymentFrequency) { + case "1": return "years" + case "2": return "semi-annual periods" + case "4": return "quarters" + case "12": return "months" + case "26": return "bi-weekly periods" + case "52": return "weeks" + case "365": return "days" + default: return "periods" + } + }, [effectivePaymentFrequency]) + + const getPaymentPeriodLabel = useCallback((): string => { + const option = FREQUENCY_OPTIONS.find(o => o.value === effectivePaymentFrequency) + return option ? option.singular : "period" + }, [effectivePaymentFrequency]) + + const formatCurrency = (value: number): string => { + const isNeg = value < 0 + return (isNeg ? "-" : "") + new Intl.NumberFormat("en-US", { + style: "currency", currency: "USD", + minimumFractionDigits: 2, maximumFractionDigits: 2, + }).format(Math.abs(value)) + } + + const formatNumber = (value: number, decimals = 4): string => + new Intl.NumberFormat("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value) + + const formatResult = (value: number): string => { + switch (solveFor) { + case "RATE": return `${formatNumber(value, 2)}%` + case "NPER": return `${formatNumber(value, 2)} ${getPeriodUnitLabel()}` + case "PMT": return `${formatCurrency(value)} per ${getPaymentPeriodLabel()}` + default: return formatCurrency(value) + } + } + + // ── Validation ───────────────────────────────────────────────────────────── + + const validateInputs = useCallback((): FieldError[] => { + const errors: FieldError[] = [] + const rawPv = presentValue.replace(/,/g, "") + const rawFv = futureValue.replace(/,/g, "") + const rawPmt = payment.replace(/,/g, "") + const rawRate = annualRate.replace(/,/g, "") + const rawPeriods = periods.replace(/,/g, "") + + if (solveFor !== "PV" && rawPv !== "" && rawPv !== "-") { + const pv = parseFloat(rawPv) + if (pv < CONSTRAINTS.presentValue.min || pv > CONSTRAINTS.presentValue.max) + errors.push({ field: "presentValue", message: `Enter an amount between -$${Math.abs(CONSTRAINTS.presentValue.min).toLocaleString()} and $${CONSTRAINTS.presentValue.max.toLocaleString()}.` }) + } + + if (solveFor !== "FV" && rawFv !== "" && rawFv !== "-") { + const fv = parseFloat(rawFv) + if (fv < CONSTRAINTS.futureValue.min || fv > CONSTRAINTS.futureValue.max) + errors.push({ field: "futureValue", message: `Enter an amount between -$${Math.abs(CONSTRAINTS.futureValue.min).toLocaleString()} and $${CONSTRAINTS.futureValue.max.toLocaleString()}.` }) + } + + if (solveFor !== "PMT" && rawPmt !== "" && rawPmt !== "-") { + const pmt = parseFloat(rawPmt) + if (pmt < CONSTRAINTS.payment.min || pmt > CONSTRAINTS.payment.max) + errors.push({ field: "payment", message: `Enter an amount between -$${Math.abs(CONSTRAINTS.payment.min).toLocaleString()} and $${CONSTRAINTS.payment.max.toLocaleString()}.` }) + } + + if (solveFor !== "RATE" && rawRate !== "" && rawRate !== "-") { + const rate = parseFloat(rawRate) + if (rate < CONSTRAINTS.annualRate.min || rate > CONSTRAINTS.annualRate.max) + errors.push({ field: "annualRate", message: `Enter a rate between ${CONSTRAINTS.annualRate.min}% and ${CONSTRAINTS.annualRate.max.toLocaleString()}%.` }) + } + + if (solveFor !== "NPER" && rawPeriods !== "") { + const n = parseFloat(rawPeriods) + const maxPeriods = 500 * parseFloat(effectivePaymentFrequency) + if (n < 0 || n > maxPeriods) + errors.push({ field: "periods", message: `Enter a number of periods between 0 and ${maxPeriods.toLocaleString()}.` }) + } + + return errors + }, [presentValue, futureValue, payment, annualRate, periods, solveFor, effectivePaymentFrequency]) + + const getFieldError = (field: string): string | undefined => + fieldErrors.find(e => e.field === field)?.message + + const displayError = calcError || signError + + // ── Calculation ──────────────────────────────────────────────────────────── + + const calculate = useCallback(() => { + setCalcError("") + setSignError("") + setResultOverflow(false) + + // Completeness guard: suppress results until every required input is filled + const requiredFields: Record = { + FV: [annualRate, periods], + PV: [annualRate, periods], + PMT: [annualRate, periods], + RATE: [periods], + NPER: [annualRate], + }; + const allFilled = requiredFields[solveFor].every(f => f.trim() !== "") + + // Always validate filled fields so range errors show as soon as all + // required fields have values; only suppress the result when incomplete. + const errors = validateInputs() + setFieldErrors(errors) + + if (!allFilled) { + setResult(null) + return + } + + if (errors.length > 0) { setResult(null); return } + + const pv = parseFloat(presentValue.replace(/,/g, "")) || 0 + const fv = parseFloat(futureValue.replace(/,/g, "")) || 0 + const pmt = parseFloat(payment.replace(/,/g, "")) || 0 + const rate = (parseFloat(annualRate.replace(/,/g, "")) || 0) / 100 + const compFreq = parseFloat(compoundingFrequency) || 1 + const pmtFreq = parseFloat(effectivePaymentFrequency) || 1 + const n = parseFloat(periods) || 0 + + let ratePerPeriod: number + if (paymentFrequencyMode === "different" && compFreq !== pmtFreq) { + ratePerPeriod = Math.pow(1 + rate / compFreq, compFreq / pmtFreq) - 1 + } else { + ratePerPeriod = rate / compFreq + } + + const timingMultiplier = paymentTiming === "beginning" ? (1 + ratePerPeriod) : 1 + + try { + // M1: sign-change guard for RATE and NPER + if (solveFor === "RATE" || solveFor === "NPER") { + const allPositive = pmt === 0 ? (pv > 0 && fv > 0) : (pv > 0 && pmt > 0 && fv > 0) + const allNegative = pmt === 0 ? (pv < 0 && fv < 0) : (pv < 0 && pmt < 0 && fv < 0) + if (allPositive || allNegative) { + const which = solveFor === "RATE" ? "rate" : "number of periods" + setSignError( + `The ${which} can't be solved for. To find the ${which}, money paid out and money received need opposite signs. Enter what you pay as negative and what you receive as positive.` + ) + setResult(null) + return + } + } + + let calculatedValue: number + + switch (solveFor) { + case "FV": { + if (ratePerPeriod === 0) { + calculatedValue = -(pv + pmt * n) + } else { + const cf = Math.pow(1 + ratePerPeriod, n) + calculatedValue = -(pv * cf + pmt * ((cf - 1) / ratePerPeriod) * timingMultiplier) + } + break + } + + case "PV": { + if (ratePerPeriod === 0) { + calculatedValue = -(fv + pmt * n) + } else { + const cf = Math.pow(1 + ratePerPeriod, n) + calculatedValue = -(fv / cf + pmt * ((cf - 1) / (ratePerPeriod * cf)) * timingMultiplier) + } + break + } + + case "PMT": { + if (ratePerPeriod === 0) { + if (n === 0) throw new Error("Number of periods must be greater than 0") + calculatedValue = -(fv + pv) / n + } else { + const cf = Math.pow(1 + ratePerPeriod, n) + calculatedValue = -(pv * cf + fv) / (((cf - 1) / ratePerPeriod) * timingMultiplier) + } + break + } + + case "RATE": { + if (n <= 0) throw new Error("Number of periods must be greater than 0") + if (pmt === 0) { + if (pv === 0) throw new Error("Present value cannot be 0 when payment is 0") + const ratio = -fv / pv + if (ratio <= 0) { + if (fv === 0 && pv !== 0) { calculatedValue = -100; break } + // M3: iterative solve couldn't converge + throw new Error("No interest rate produces these values. Check that your cash flows include both a negative and a positive amount.") + } + const ratePerPeriodCalc = Math.pow(ratio, 1 / n) - 1 + calculatedValue = + paymentFrequencyMode === "different" && compFreq !== pmtFreq + ? (Math.pow(1 + ratePerPeriodCalc, pmtFreq / compFreq) - 1) * + compFreq * + 100 + : ratePerPeriodCalc * compFreq * 100; + break + } + // Solve for the per-period rate (g): scan for a sign change, then + // bisect. This replaced an earlier Newton-Raphson solver, which was + // seed-sensitive and failed for high rates. Scan-then-bisect has no + // seed sensitivity and handles a root tucked against a peak; it + // reports M3 only when there is genuinely no crossing in the range. + // + // The search runs in per-period-rate space (g), not annual space. + // The per-period rate is frequency-independent, so the solver is + // equally capable at every compounding frequency and the scan + // resolution stays constant. The previous version scanned annual + // space and reused the input-field cap (CONSTRAINTS.annualRate.max, + // 1,000%) as its ceiling, which made valid high-frequency roots + // unreachable: PV 1,000, PMT -50, N 60 solves at 243.27% weekly but + // 1,707.54% daily, and the daily root sat above the 1,000% ceiling + // and was wrongly reported as unsolvable. + // + // `objective` is written in the rate per PAYMENT period (g), applied + // against `n` (a count of payment periods). + const objective = (g: number): number => { + if (g === 0) return pv + pmt * n + fv + const cf = Math.pow(1 + g, n) + const tm = paymentTiming === "beginning" ? 1 + g : 1 + return pv * cf + pmt * ((cf - 1) / g) * tm + fv + } + + // annualToG converts a candidate ANNUAL rate to g; gToAnnual is its + // inverse. Both use the same annualization the rest of the calculator + // uses, so "same" and "different" frequency modes stay consistent. + const annualToG = (annual: number): number => + paymentFrequencyMode === "different" && compFreq !== pmtFreq + ? Math.pow(annual / (compFreq * 100) + 1, compFreq / pmtFreq) - 1 + : annual / 100 / compFreq + + const gToAnnual = (g: number): number => + paymentFrequencyMode === "different" && compFreq !== pmtFreq + ? (Math.pow(1 + g, pmtFreq / compFreq) - 1) * compFreq * 100 + : g * compFreq * 100 + + // Output range (per product decision): solved rate is uncapped on the + // high side (a computed 1,707% is a real answer) but floored at -100% + // (a rate at or below -100% is nonsensical). The scan floor is the g + // that maps to the -99.99% input floor; the post-switch guard + // enforces the hard -100% floor for the pmt === 0 path. + const G_LO = annualToG(CONSTRAINTS.annualRate.min) // maps to -99.99% annual + const G_HI = 10 // 1,000% per period; generous, frequency-independent + const G_STEPS = 2000 + + // Scan the whole range and keep the LAST (highest-rate) sign change. + // Some cash flows change sign twice and admit two valid rates; the + // economically meaningful answer is the higher one. For the common + // single-root case there is exactly one bracket, so this is identical + // to taking the first one. + let bracketLo: number | null = null + let bracketHi = 0 + let bracketFLo = 0 + let prevG = G_LO + let prevF = objective(prevG) + + if (prevF === 0) { bracketLo = G_LO; bracketHi = G_LO; bracketFLo = 0 } + + for (let i = 1; i <= G_STEPS; i++) { + const g = G_LO + ((G_HI - G_LO) * i) / G_STEPS + const f = objective(g) + if (f === 0) { + bracketLo = g; bracketHi = g; bracketFLo = 0 + prevG = g; prevF = f + continue + } + // Sign change between prevG and g: remember this bracket. + if ((f > 0) !== (prevF > 0)) { + bracketLo = prevG; bracketHi = g; bracketFLo = prevF + } + prevG = g + prevF = f + } + + if (bracketLo === null) + // M3 + throw new Error("No interest rate produces these values. Check that your cash flows include both a negative and a positive amount.") + + let solvedG = (bracketLo + bracketHi) / 2 + if (bracketLo !== bracketHi) { + let lo = bracketLo + let hi = bracketHi + let fLo = bracketFLo + for (let j = 0; j < 100; j++) { + const mid = (lo + hi) / 2 + const fMid = objective(mid) + solvedG = mid + if (Math.abs(fMid) < 1e-7 || hi - lo < 1e-10) break + if ((fMid > 0) === (fLo > 0)) { lo = mid; fLo = fMid } + else { hi = mid } + } + } + + // solvedG is a per-period rate; annualize it for display. + calculatedValue = gToAnnual(solvedG) + break + } + + case "NPER": { + if (ratePerPeriod === 0) { + if (pmt === 0) throw new Error("Payment cannot be 0 when rate is 0") + const nper = -(pv + fv) / pmt + if (!isFinite(nper) || nper <= 0) + // M2 + throw new Error("The number of periods cannot be solved for. With the current payment and rate, the target cannot be reached.") + calculatedValue = nper + } else { + const pmtAdj = pmt * timingMultiplier + const numerator = pmtAdj - fv * ratePerPeriod + const denom = pmtAdj + pv * ratePerPeriod + if (denom === 0 || numerator / denom <= 0) + // M2 + throw new Error("The number of periods cannot be solved for. With the current payment and rate, the target cannot be reached.") + const nper = Math.log(numerator / denom) / Math.log(1 + ratePerPeriod) + if (!isFinite(nper) || nper <= 0) + // M2 + throw new Error("The number of periods cannot be solved for. With the current payment and rate, the target cannot be reached.") + calculatedValue = nper + } + break + } + + default: + throw new Error("Invalid calculation type") + } + + if (!isFinite(calculatedValue) || isNaN(calculatedValue)) + throw new Error("Invalid result. Please check your inputs.") + + // Output floor for RATE: never show a solved rate at or below -100%. + // Such a rate can arise via nominal annualization at high compounding + // frequencies (e.g. pmt = 0 with |fv| far below |pv|) but is nonsensical. + // The high side is intentionally uncapped. -100% exactly is allowed: it + // is the legitimate total-loss result when fv = 0. + if (solveFor === "RATE" && calculatedValue < -100) + throw new Error("No interest rate produces these values. Check that your cash flows include both a negative and a positive amount.") + + // M5: output overflow ceiling — never render scientific notation + if ( + (solveFor === "FV" || solveFor === "PV" || solveFor === "PMT") && + Math.abs(calculatedValue) >= OUTPUT_OVERFLOW_CEILING + ) { + setResultOverflow(true) + setResult(null) + return + } + + setResult(calculatedValue) + } catch (err) { + setCalcError(err instanceof Error ? err.message : "Calculation error") + setResult(null) + } + }, [ + presentValue, futureValue, payment, annualRate, periods, + compoundingFrequency, effectivePaymentFrequency, + paymentFrequencyMode, paymentTiming, solveFor, validateInputs, + ]) + + useEffect(() => { calculate() }, [calculate]) + + useEffect(() => { + const seen = localStorage.getItem("tvm_howto_seen") + if (!seen) { + localStorage.setItem("tvm_howto_seen", "true") + setShowHowToUse(true) + } + }, []) + + // ── Reset ────────────────────────────────────────────────────────────────── + + const clearAll = () => { + setPresentValue(""); setFutureValue(""); setPayment("") + setAnnualRate(""); setPeriods("") + setCompoundingFrequency("1"); setPaymentFrequencyMode("same") + setPaymentFrequency("12"); setPaymentTiming("end") + setResult(null); setResultOverflow(false) + setCalcError(""); setSignError(""); setFieldErrors([]) + } + + // ── Examples ─────────────────────────────────────────────────────────────── + + const loadExample = (example: { + solveFor: SolveFor + pv?: string; fv?: string; pmt?: string; rate?: string + periods?: string; compoundFreq?: string; paymentFreq?: string + paymentFreqMode?: PaymentFrequencyMode + }) => { + setSolveFor(example.solveFor) + if (example.pv !== undefined) setPresentValue(formatWithCommas(example.pv)) + if (example.fv !== undefined) setFutureValue(formatWithCommas(example.fv)) + if (example.pmt !== undefined) setPayment(formatWithCommas(example.pmt)) + if (example.rate !== undefined) setAnnualRate(example.rate) + if (example.periods !== undefined) setPeriods(example.periods) + if (example.compoundFreq !== undefined) setCompoundingFrequency(example.compoundFreq) + if (example.paymentFreq !== undefined) setPaymentFrequency(example.paymentFreq) + if (example.paymentFreqMode !== undefined) setPaymentFrequencyMode(example.paymentFreqMode) + } + + const getExample = () => { + if (exampleMode === "saving") { + switch (solveFor) { + case "FV": return { + title: "Future Value Example: Solve for a future savings balance", + bullets: [ + "Present value (initial deposit): negative", + "Payment per period (ongoing deposits): negative", + "Future value (account balance at the end): positive", + ], + example: { solveFor: "FV" as SolveFor, pv: "-10000", pmt: "-5000", rate: "8", periods: "40", compoundFreq: "1", paymentFreq: "1" }, + } + case "PV": return { + title: "Present Value Example: Solve for the initial deposit needed to reach a savings goal", + bullets: [ + "Payment per period (ongoing deposits): negative", + "Future value (target savings goal): positive", + "Present value (initial deposit needed): negative", + ], + example: { solveFor: "PV" as SolveFor, pmt: "-250", fv: "20000", rate: "4", periods: "36", compoundFreq: "12", paymentFreq: "12" }, + } + case "PMT": return { + title: "Payment Example: Solve for the required savings contribution to reach a savings goal", + bullets: [ + "Present value (initial deposit): negative", + "Future value (target savings goal): positive", + "Payment per period (required contribution): negative", + ], + example: { solveFor: "PMT" as SolveFor, pv: "-50000", fv: "1000000", rate: "7", periods: "30", compoundFreq: "1", paymentFreq: "1" }, + } + case "RATE": return { + title: "Interest Rate Example: Solve for the interest rate earned", + bullets: [ + "Present value (initial deposit): negative", + "Payment per period (ongoing deposits): negative", + "Future value (ending balance): positive", + ], + example: { solveFor: "RATE" as SolveFor, pv: "-10000", pmt: "-2000", fv: "150000", periods: "30", compoundFreq: "1", paymentFreq: "1" }, + } + case "NPER": return { + title: "Number of Periods Example: Solve for time to reach savings goal", + bullets: [ + "Present value (initial deposit): negative", + "Payment per period (ongoing deposits): negative", + "Future value (target savings goal): positive", + ], + example: { solveFor: "NPER" as SolveFor, pv: "-5000", pmt: "-300", fv: "30000", rate: "3.5", compoundFreq: "12", paymentFreqMode: "different" as PaymentFrequencyMode, paymentFreq: "26" }, + } + } + } else { + switch (solveFor) { + case "FV": return { + title: "Future Value Example: Solve for remaining loan balance", + bullets: [ + "Present value (loan amount received): positive", + "Payment per period (loan payments): negative", + "Future value (remaining balance): negative or zero", + ], + example: { solveFor: "FV" as SolveFor, pv: "250000", pmt: "-1500", rate: "6.5", periods: "120", compoundFreq: "12", paymentFreq: "12" }, + } + case "PV": return { + title: "Present Value Example: Solve for the loan amount you can afford", + bullets: [ + "Payment per period (loan payments): negative", + "Future value (remaining balance): zero", + "Present value (loan amount): positive", + ], + example: { solveFor: "PV" as SolveFor, pmt: "-400", fv: "0", rate: "6.5", periods: "60", compoundFreq: "12", paymentFreq: "12" }, + } + case "PMT": return { + title: "Payment Example: Solve for the loan payment amount", + bullets: [ + "Present value (loan amount): positive", + "Future value (remaining balance): zero", + "Payment per period (required payment): negative", + ], + example: { solveFor: "PMT" as SolveFor, pv: "400000", fv: "0", rate: "6", periods: "360", compoundFreq: "12", paymentFreq: "12" }, + } + case "RATE": return { + title: "Interest Rate Example: Solve for the loan interest rate", + bullets: [ + "Present value (loan amount): positive", + "Payment per period (loan payments): negative", + "Future value (remaining balance): zero", + ], + example: { solveFor: "RATE" as SolveFor, pv: "25000", pmt: "-500", fv: "0", periods: "60", compoundFreq: "12", paymentFreq: "12" }, + } + case "NPER": return { + title: "Number of Periods Example: Solve for time to pay off credit card", + bullets: [ + "Present value (credit card balance): positive", + "Payment per period (bi-weekly payments): negative", + "Future value (remaining balance): zero", + ], + example: { solveFor: "NPER" as SolveFor, pv: "4000", pmt: "-250", fv: "0", rate: "18", compoundFreq: "365", paymentFreq: "26", paymentFreqMode: "different" as PaymentFrequencyMode }, + } + } + } + } + + // ── Sub-components ───────────────────────────────────────────────────────── + + const currentOption = SOLVE_OPTIONS.find(o => o.value === solveFor) + const currentExample = getExample() + + // ── Result panel content ─────────────────────────────────────────────────── + + const ResultDisplay = ({ + size = "desktop", + className = "", + }: { + size?: "desktop" | "mobile"; + className?: string; + }) => { + const largeText = size === "desktop" ? "text-3xl/normal" : "text-2xl"; + const medText = size === "desktop" ? "text-2xl sm:text-3xl" : "text-xl"; + + if (displayError) { + return ( +

+ {displayError} +

+ ); + } + if (resultOverflow) { + return ( +
+

+ Too large to display. +

+

Try a lower rate or fewer periods.

+
+ ); + } + if (result !== null) { + return ( +

+ {formatResult(result)} +

+ ); + } + return

; + }; + + // ── Render ───────────────────────────────────────────────────────────────── + + return ( +
- {/* Header */}

Time Value of Money Calculator

- + + + {/* Solve-for tabs */} +
+

Solve for

+ { + const next = value as SolveFor; + if (next !== solveFor) { + setPresentValue(""); + setFutureValue(""); + setPayment(""); + setAnnualRate(""); + setPeriods(""); + setPaymentFrequencyMode("same"); + setPaymentTiming("end"); + } + setSolveFor(next); + }} + > + + {SOLVE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
+ + {/* How To Use Info Accordion box */} +
+ + + {showHowToUse && ( +
+
+

Cash Flow Signs

+

+ This calculator uses signs to show the direction of money: +

+
+
+ + Positive: money you receive (cash in) +
+
+ + Negative: money you pay (cash out) +
+
+ +
+
+ Example, I am: +
+ {(["saving", "borrowing"] as const).map((mode, index) => ( + + ))} +
+
+ + {currentExample && ( + <> +

+ {currentExample.title} +

+
    + {currentExample.bullets.map((bullet, idx) => ( +
  • {bullet}
  • + ))} +
+ + + )} +
+
+
+ )} +
+ +
+ {/* Input Fields */} +
+ {/* Present Value */} + {solveFor !== "PV" && ( +
+ +
+ + $ + + + handleInputChange(e.target.value, setPresentValue) + } + onBlur={(e) => + handleInputBlur(e.target.value, setPresentValue) + } + className={`border-border pl-7 bg-card ${getFieldError("presentValue") ? "border-2 border-[var(--color-inline-error)]" : ""}`} + /> +
+ {getFieldError("presentValue") && ( +

+ {getFieldError("presentValue")} +

+ )} +
+ )} + + {/* Payment — early position for RATE/NPER */} + {(solveFor === "RATE" || solveFor === "NPER") && ( +
+ +
+ + $ + + + handleInputChange(e.target.value, setPayment) + } + onBlur={(e) => handleInputBlur(e.target.value, setPayment)} + className={`border-border pl-7 bg-card ${getFieldError("payment") ? "border-2 border-[var(--color-inline-error)]" : ""}`} + /> +
+ {getFieldError("payment") && ( +

+ {getFieldError("payment")} +

+ )} +
+ )} + + {/* Future Value */} + {solveFor !== "FV" && ( +
+ +
+ + $ + + + handleInputChange(e.target.value, setFutureValue) + } + onBlur={(e) => + handleInputBlur(e.target.value, setFutureValue) + } + className={`border-border pl-7 bg-card ${getFieldError("futureValue") ? "border-2 border-[var(--color-inline-error)]" : ""}`} + /> +
+ {getFieldError("futureValue") && ( +

+ {getFieldError("futureValue")} +

+ )} +
+ )} + + {/* Payment — normal position for FV/PV */} + {solveFor !== "PMT" && + solveFor !== "RATE" && + solveFor !== "NPER" && ( +
+ +
+ + $ + + + handleInputChange(e.target.value, setPayment) + } + onBlur={(e) => + handleInputBlur(e.target.value, setPayment) + } + className={`border-border pl-7 bg-card ${getFieldError("payment") ? "border-2 border-[var(--color-inline-error)]" : ""}`} + /> +
+ {getFieldError("payment") && ( +

+ {getFieldError("payment")} +

+ )} +
+ )} + + {/* Annual Interest Rate */} + {solveFor !== "RATE" && ( +
+ +
+ + handleInputChange(e.target.value, setAnnualRate) + } + onBlur={(e) => + handleInputBlur(e.target.value, setAnnualRate) + } + className={`border-border pr-8 bg-card ${getFieldError("annualRate") ? "border-2 border-[var(--color-inline-error)]" : ""}`} + /> + + % + +
+ {getFieldError("annualRate") && ( +

+ {getFieldError("annualRate")} +

+ )} +
+ )} + + {/* Number of Periods */} + {solveFor !== "NPER" && ( +
+ + { + const val = e.target.value; + if (val === "" || /^\d*$/.test(val)) setPeriods(val); + }} + className={`border-border bg-card ${getFieldError("periods") ? "border-2 border-[var(--color-inline-error)]" : ""}`} + /> + {getFieldError("periods") && ( +

+ {getFieldError("periods")} +

+ )} +
+ )} + + {/* Compounding Frequency */} +
+ + +
+ + {/* Payment Frequency */} +
+ +
+ {(["same", "different"] as const).map((mode) => ( + + ))} +
+ {paymentFrequencyMode === "different" && ( +
+ + +
+ )} +
+ + {/* Payment Timing */} +
+ +
+ {(["end", "beginning"] as const).map((timing, i) => ( + + ))} +
+

+ {paymentTiming === "end" + ? "Payment made at the end of the period" + : "Payment made at the beginning of the period"} +

+
+ +
+ +
+
+ + {/* Results Card — desktop */} + + +

+ {currentOption?.label} +

+ +
+
+
+ + {/* Mobile sticky footer */} +
+
+
{currentOption?.label}
+ + +
- ) - } \ No newline at end of file +
+ ); +} diff --git a/app/ui/globals.css b/app/ui/globals.css index 0d760d4..ef832a0 100644 --- a/app/ui/globals.css +++ b/app/ui/globals.css @@ -120,12 +120,16 @@ --foreground: oklch(0.145 0 0); --button-green: rgba(30, 113, 102, 1); --button-berry: rgba(151, 24, 87, 1); + --color-berry: rgba(195, 31, 112, 1); --color-teal: rgba(0, 124, 146, 1); + --color-palo-verde-var: rgba(39, 153, 137, 1); --color-symbols: #616877; --color-inline-error: #8C1515; --color-inline-warning: #9A6207; --results-card-empty: rgba(102, 112, 133, 1); --info-popup-background: rgba(239, 255, 252, 1); + --sticky-footer-background: #95C0CB; + --sticky-footer-text: #000000; } .dark { @@ -179,7 +183,9 @@ --additional-background: rgb(52, 51, 51); --button-green: rgba(30, 113, 102, 1); --button-berry: rgba(151, 24, 87, 1); + --color-berry: #EC83B8; --color-teal: rgba(74, 203, 225, 1); + --color-palo-verde-var: #38CCB8; --color-symbols: oklch(0.985 0 0); --color-inline-error: #F4795B; --color-inline-warning: #C37C09;