From a65999ce79cdf9f3fe0465d3e2ea9ff205522f18 Mon Sep 17 00:00:00 2001 From: Jen Breese-Kauth Date: Tue, 23 Jun 2026 13:33:29 -0700 Subject: [PATCH 01/32] Error updates for the TVM calculator --- .../time-value-money-calculator/app.tsx | 1073 ----------------- .../time-value-money-calculator/page.tsx | 952 ++++++++++++++- 2 files changed, 943 insertions(+), 1082 deletions(-) delete mode 100644 app/interactives/time-value-money-calculator/app.tsx diff --git a/app/interactives/time-value-money-calculator/app.tsx b/app/interactives/time-value-money-calculator/app.tsx deleted file mode 100644 index b6e7814..0000000 --- a/app/interactives/time-value-money-calculator/app.tsx +++ /dev/null @@ -1,1073 +0,0 @@ -"use client" - -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" - - -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." }, -] - -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: -1_000_000, max: 1_000_000 }, - annualRate: { min: -100, max: 100 }, - periods: { min: 0, max: 12000 }, -} - -interface FieldError { - field: string - message: string -} - -const SIGN_HELPER = "Money you pay out is negative; money you receive is positive." - -export function TVMCalculator() { - const [presentValue, setPresentValue] = useState("") - const [futureValue, setFutureValue] = useState("") - const [payment, setPayment] = useState("") - const [annualRate, setAnnualRate] = useState("") - const [periods, setPeriods] = useState("") - const [compoundingFrequency, setCompoundingFrequency] = useState("12") - const [paymentFrequencyMode, setPaymentFrequencyMode] = useState("same") - const [paymentFrequency, setPaymentFrequency] = useState("12") - const [paymentTiming, setPaymentTiming] = useState("end") - const [solveFor, setSolveFor] = useState("FV") - const [result, setResult] = useState(null) - const [calcError, setCalcError] = useState("") - const [fieldErrors, setFieldErrors] = useState([]) - const [signError, setSignError] = useState("") - const [showHowToUse, setShowHowToUse] = useState(false) - const [exampleMode, setExampleMode] = useState<"saving" | "borrowing">("saving") - - 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 effectivePaymentFrequency = useMemo(() => { - return paymentFrequencyMode === "same" ? compoundingFrequency : paymentFrequency - }, [paymentFrequencyMode, compoundingFrequency, paymentFrequency]) - - const getPeriodUnitLabel = useCallback((): string => { - const freq = effectivePaymentFrequency - const option = FREQUENCY_OPTIONS.find(o => o.value === freq) - if (!option) return "periods" - switch (option.value) { - 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 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 $${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 $${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 $${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}%` }) - } - if (solveFor !== "NPER" && rawPeriods !== "") { - const n = parseFloat(rawPeriods) - if (n < CONSTRAINTS.periods.min || n > CONSTRAINTS.periods.max) - errors.push({ field: "periods", message: `Enter a value between ${CONSTRAINTS.periods.min} and ${CONSTRAINTS.periods.max} periods` }) - } - return errors - }, [presentValue, futureValue, payment, annualRate, periods, solveFor]) - - const getFieldError = (field: string): string | undefined => - fieldErrors.find(e => e.field === field)?.message - - const displayError = calcError || signError - - const calculate = useCallback(() => { - setCalcError("") - setSignError("") - - // ── Don't calculate (or show errors) until all required fields are filled ── - const requiredFields: Record = { - FV: [presentValue, annualRate, periods], - PV: [futureValue, annualRate, periods], - PMT: [presentValue, futureValue, annualRate, periods], - RATE: [presentValue, futureValue, periods], - NPER: [presentValue, futureValue, annualRate], - }; - const allFilled = requiredFields[solveFor].every((f) => f.trim() !== ""); - if (!allFilled) { - setResult(null); - setFieldErrors([]); - return; - } - - const errors = validateInputs() - setFieldErrors(errors) - 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 { - // Require opposite signs for RATE/NPER solves: at least one cash flow must have opposite sign. - // When pmt === 0, pv and fv alone must have opposite signs. - 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( - `These values can't be solved. To find the ${which}, money paid out and money received need opposite signs — for example, enter what you invest 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 compoundFactor = Math.pow(1 + ratePerPeriod, n) - calculatedValue = -(pv * compoundFactor + pmt * ((compoundFactor - 1) / ratePerPeriod) * timingMultiplier) - } - break - } - case "PV": { - if (ratePerPeriod === 0) { - calculatedValue = -(fv - pmt * n) - } else { - const compoundFactor = Math.pow(1 + ratePerPeriod, n) - calculatedValue = -(fv / compoundFactor + pmt * ((compoundFactor - 1) / (ratePerPeriod * compoundFactor)) * timingMultiplier) - } - break - } - case "PMT": { - if (ratePerPeriod === 0) { - if (n === 0 && periods === "0") throw new Error("Number of periods must be greater than 0") - calculatedValue = n === 0 ? 0 : -(fv + pv) / n - } else { - const compoundFactor = Math.pow(1 + ratePerPeriod, n) - calculatedValue = -(pv * compoundFactor + fv) / (((compoundFactor - 1) / ratePerPeriod) * timingMultiplier) - } - break - } - case "RATE": { - if (n <= 0 && periods === "0") throw new Error("Number of periods must be greater than 0") - if (n <= 0) { calculatedValue = 0; break } - 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 } - throw new Error("Cannot find a valid interest rate for these inputs") - } - const ratePerPeriodCalc = Math.pow(ratio, 1 / n) - 1 - calculatedValue = paymentFrequencyMode === "different" && compFreq !== pmtFreq - ? (Math.pow(1 + ratePerPeriodCalc, pmtFreq) - 1) * 100 - : ratePerPeriodCalc * compFreq * 100 - break - } - let guess = 0.1 / compFreq - let calculatedRate: number | null = null - for (let i = 0; i < 100; i++) { - const currentTiming = paymentTiming === "beginning" ? (1 + guess) : 1 - const cf = Math.pow(1 + guess, n) - const eq = guess === 0 ? pv + pmt * n + fv : pv * cf + pmt * ((cf - 1) / guess) * currentTiming + fv - if (Math.abs(eq) < 0.0001) { - calculatedRate = paymentFrequencyMode === "different" && compFreq !== pmtFreq - ? (Math.pow(1 + guess, pmtFreq) - 1) * 100 - : guess * compFreq * 100 - break - } - const delta = 0.0001 - const nextTiming = paymentTiming === "beginning" ? (1 + guess + delta) : 1 - const cfD = Math.pow(1 + guess + delta, n) - const eqD = (guess + delta) === 0 ? pv + pmt * n + fv : pv * cfD + pmt * ((cfD - 1) / (guess + delta)) * nextTiming + fv - const derivative = (eqD - eq) / delta - if (Math.abs(derivative) < 0.0000001) throw new Error("Cannot converge to a solution") - guess = guess - eq / derivative - if (i === 99) throw new Error("Could not find a solution for rate") - } - if (calculatedRate === null) throw new Error("Could not find a solution for rate") - calculatedValue = calculatedRate - break - } - case "NPER": { - if (ratePerPeriod === 0) { - if (pmt === 0 && payment === "0") throw new Error("Payment cannot be 0 when rate is 0") - const calculatedNper = pmt === 0 ? 0 : -(pv + fv) / pmt - if (!isFinite(calculatedNper) || calculatedNper <= 0) { - throw new Error("These payments are too small to reach this future value. Try a larger payment, a higher rate, or a lower target.") - } - calculatedValue = calculatedNper - } else { - const pmtAdj = pmt * timingMultiplier - const numerator = pmtAdj - fv * ratePerPeriod - const denominator = pmtAdj + pv * ratePerPeriod - const ratio = numerator / denominator - if (ratio <= 0) { - throw new Error("These payments are too small to reach this future value. Try a larger payment, a higher rate, or a lower target.") - } - const calculatedNper = Math.log(ratio) / Math.log(1 + ratePerPeriod) - if (!isFinite(calculatedNper) || calculatedNper <= 0) { - throw new Error("These payments are too small to reach this future value. Try a larger payment, a higher rate, or a lower target.") - } - calculatedValue = calculatedNper - } - break - } - default: - throw new Error("Invalid calculation type") - } - - if (!isFinite(calculatedValue) || isNaN(calculatedValue)) - throw new Error("Invalid result. Please check your inputs.") - - 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]) - - const clearAll = () => { - setPresentValue(""); setFutureValue(""); setPayment("") - setAnnualRate(""); setPeriods("") - setCompoundingFrequency("12"); setPaymentFrequencyMode("same") - setPaymentFrequency("12"); setPaymentTiming("end") - setResult(null); setCalcError(""); setFieldErrors([]) - } - - const formatCurrency = (value: number): string => { - const isNegative = value < 0 - return (isNegative ? "-" : "") + 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) - } - } - - const formatWithCommas = (value: string): string => { - const rawValue = value.replace(/,/g, "") - if (rawValue === "" || rawValue === "-" || rawValue === "." || rawValue === "-.") return rawValue - const parts = rawValue.split(".") - const isNegative = parts[0].startsWith("-") - const absInteger = isNegative ? parts[0].slice(1) : parts[0] - const formatted = absInteger.replace(/\B(?=(\d{3})+(?!\d))/g, ",") - const signed = isNegative ? "-" + formatted : formatted - return parts.length > 1 ? signed + "." + parts[1] : signed - } - - const handleInputChange = (value: string, setter: (val: string) => void) => { - const rawValue = value.replace(/,/g, "") - if (rawValue === "" || rawValue === "-" || rawValue === "." || rawValue === "-.") { setter(rawValue); return } - if (/^-?\d*\.?\d*$/.test(rawValue)) setter(formatWithCommas(rawValue)) - } - - const handleInputBlur = (value: string, setter: (val: string) => void) => { - const rawValue = value.replace(/,/g, "") - if (rawValue.startsWith(".")) setter(formatWithCommas("0" + rawValue)) - else if (rawValue.startsWith("-.")) setter(formatWithCommas("-0" + rawValue.slice(1))) - } - - const currentOption = SOLVE_OPTIONS.find(o => o.value === solveFor) - - 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, - }, - }; - } - } - } - - const HowToUseInfoBox = () => { - const currentExample = getExample() - return ( -
- - {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..442a354 100644 --- a/app/interactives/time-value-money-calculator/page.tsx +++ b/app/interactives/time-value-money-calculator/page.tsx @@ -1,14 +1,948 @@ "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" + +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 +} + +const SIGN_HELPER = "Money you pay out is negative; money you receive is positive." + +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("12") + const [paymentFrequencyMode, setPaymentFrequencyMode] = useState("same") + const [paymentFrequency, setPaymentFrequency] = useState("12") + 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 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 $${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 $${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 $${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: [presentValue, annualRate, periods], + PV: [futureValue, annualRate, periods], + PMT: [presentValue, futureValue, annualRate, periods], + RATE: [presentValue, futureValue, periods], + NPER: [presentValue, futureValue, 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 — for example, 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) - 1) * 100 + : ratePerPeriodCalc * compFreq * 100 + break + } + // Newton-Raphson solve + let guess = 0.1 / compFreq + let calculatedRate: number | null = null + for (let i = 0; i < 100; i++) { + const currentTiming = paymentTiming === "beginning" ? (1 + guess) : 1 + const cf = Math.pow(1 + guess, n) + const eq = guess === 0 + ? pv + pmt * n + fv + : pv * cf + pmt * ((cf - 1) / guess) * currentTiming + fv + if (Math.abs(eq) < 0.0001) { + calculatedRate = paymentFrequencyMode === "different" && compFreq !== pmtFreq + ? (Math.pow(1 + guess, pmtFreq) - 1) * 100 + : guess * compFreq * 100 + break + } + const delta = 0.0001 + const nextTiming = paymentTiming === "beginning" ? (1 + guess + delta) : 1 + const cfD = Math.pow(1 + guess + delta, n) + const eqD = (guess + delta) === 0 + ? pv + pmt * n + fv + : pv * cfD + pmt * ((cfD - 1) / (guess + delta)) * nextTiming + fv + const deriv = (eqD - eq) / delta + if (Math.abs(deriv) < 0.0000001) + // M3 + throw new Error("No interest rate produces these values. Check that your cash flows include both a negative and a positive amount.") + guess = guess - eq / deriv + if (i === 99) + // M3 + throw new Error("No interest rate produces these values. Check that your cash flows include both a negative and a positive amount.") + } + if (calculatedRate === null) + throw new Error("No interest rate produces these values. Check that your cash flows include both a negative and a positive amount.") + calculatedValue = calculatedRate + 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.") + + // 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("12"); 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() + + const HowToUseInfoBox = () => ( +
+ + + {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}
  • )} +
+ +
+ )} +
+
+
+ )} +
+ ) + + // ── Result panel content ─────────────────────────────────────────────────── + + const ResultDisplay = ({ size = "desktop" }: { size?: "desktop" | "mobile" }) => { + 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)} +

+ ) + } + // Incomplete / no result yet — always show "—", never $0 or stale value + return

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

Time Value of Money Calculator

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

Solve for

+
+ {SOLVE_OPTIONS.map((option) => ( + + ))} +
+
+ + + +
+ {/* 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 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 +
+ ) +} From 7adf6e0781f36d0c843e917166e0f5bfed3aa908 Mon Sep 17 00:00:00 2001 From: Jen Breese-Kauth Date: Tue, 23 Jun 2026 14:52:52 -0700 Subject: [PATCH 02/32] UI Design issues marked high or up for TVM --- .../time-value-money-calculator/page.tsx | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/app/interactives/time-value-money-calculator/page.tsx b/app/interactives/time-value-money-calculator/page.tsx index 442a354..62be932 100644 --- a/app/interactives/time-value-money-calculator/page.tsx +++ b/app/interactives/time-value-money-calculator/page.tsx @@ -15,6 +15,7 @@ import { 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" @@ -654,28 +655,29 @@ export default function Page() { {/* Solve-for tabs */}

Solve for

-
- {SOLVE_OPTIONS.map((option) => ( - - ))} -
+ { + const next = value as SolveFor + if (next !== solveFor) { + setPresentValue("") + setFutureValue("") + setPayment("") + setAnnualRate("") + setPeriods("") + setPaymentFrequencyMode("same") + } + setSolveFor(next) + }} + > + + {SOLVE_OPTIONS.map((option) => ( + + {option.label} + + ))} + +
@@ -903,8 +905,8 @@ export default function Page() { onClick={() => setPaymentTiming(timing)} className={`cursor-pointer px-4 py-2 text-sm font-medium transition-colors ${i > 0 ? "border-l border-border" : ""} ${ paymentTiming === timing - ? "bg-primary text-primary-foreground" - : "bg-card hover:text-foreground" + ? "bg-lagunita text-white" + : " hover:text-foreground" }`} > {timing.charAt(0).toUpperCase() + timing.slice(1)} From 5a10ab39c092fd5dcba1f32f03a32045f136a1ee Mon Sep 17 00:00:00 2001 From: Jen Breese-Kauth Date: Tue, 23 Jun 2026 15:14:09 -0700 Subject: [PATCH 03/32] fixup --- .../time-value-money-calculator/page.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/interactives/time-value-money-calculator/page.tsx b/app/interactives/time-value-money-calculator/page.tsx index 62be932..b568a41 100644 --- a/app/interactives/time-value-money-calculator/page.tsx +++ b/app/interactives/time-value-money-calculator/page.tsx @@ -559,20 +559,20 @@ export default function Page() {

Cash Flow Signs

This calculator uses signs to show the direction of money:

-
-
- - Positive — money you receive (cash in) +
+
+ + Positive: money you receive (cash in)
-
- - Negative — money you pay (cash out) +
+ + Negative: money you pay (cash out)
- Example — I am: + Example, I am:
{(["saving", "borrowing"] as const).map((mode, index) => ( -
+ )}
From e13c04ab6a88aad739067dda4f0a60f9d48e1b74 Mon Sep 17 00:00:00 2001 From: Jen Breese-Kauth Date: Wed, 24 Jun 2026 09:21:13 -0700 Subject: [PATCH 04/32] design fix ups and merge --- .../time-value-money-calculator/page.tsx | 51 ++++++++++++------- app/ui/globals.css | 2 + 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/app/interactives/time-value-money-calculator/page.tsx b/app/interactives/time-value-money-calculator/page.tsx index b568a41..799d9b2 100644 --- a/app/interactives/time-value-money-calculator/page.tsx +++ b/app/interactives/time-value-money-calculator/page.tsx @@ -560,12 +560,12 @@ export default function Page() {

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)
@@ -618,31 +618,44 @@ export default function Page() { // ── Result panel content ─────────────────────────────────────────────────── - const ResultDisplay = ({ size = "desktop" }: { size?: "desktop" | "mobile" }) => { - const largeText = size === "desktop" ? "text-3xl/normal" : "text-2xl" - const medText = size === "desktop" ? "text-2xl sm:text-3xl" : "text-xl" + 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}

+ return ( +

+ {displayError} +

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

Too large to display.

+
+

+ Too large to display. +

Try a lower rate or fewer periods.

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

+

{formatResult(result)}

- ) + ); } - // Incomplete / no result yet — always show "—", never $0 or stale value - return

- } + return

; + }; // ── Render ───────────────────────────────────────────────────────────────── @@ -670,7 +683,7 @@ export default function Page() { setSolveFor(next) }} > - + {SOLVE_OPTIONS.map((option) => ( {option.label} @@ -937,10 +950,10 @@ export default function Page() {
{/* Mobile sticky footer */} -
+
{currentOption?.label}
- +
diff --git a/app/ui/globals.css b/app/ui/globals.css index 0d760d4..5d2d4a4 100644 --- a/app/ui/globals.css +++ b/app/ui/globals.css @@ -126,6 +126,8 @@ --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 { From 5f0e5b1f8ed269e2d05a2cb3dccef43fb289dea5 Mon Sep 17 00:00:00 2001 From: Jen Breese-Kauth Date: Wed, 24 Jun 2026 15:38:01 -0700 Subject: [PATCH 05/32] fixup --- .../time-value-money-calculator/page.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/app/interactives/time-value-money-calculator/page.tsx b/app/interactives/time-value-money-calculator/page.tsx index 799d9b2..4f77da2 100644 --- a/app/interactives/time-value-money-calculator/page.tsx +++ b/app/interactives/time-value-money-calculator/page.tsx @@ -58,8 +58,6 @@ interface FieldError { message: string } -const SIGN_HELPER = "Money you pay out is negative; money you receive is positive." - export default function Page() { const [presentValue, setPresentValue] = useState("") const [futureValue, setFutureValue] = useState("") @@ -720,9 +718,6 @@ export default function Page() { {getFieldError("presentValue") && (

{getFieldError("presentValue")}

)} - {presentValue && ( -

{SIGN_HELPER}

- )}
)} @@ -747,9 +742,6 @@ export default function Page() { {getFieldError("payment") && (

{getFieldError("payment")}

)} - {payment && ( -

{SIGN_HELPER}

- )}
)} @@ -774,9 +766,6 @@ export default function Page() { {getFieldError("futureValue") && (

{getFieldError("futureValue")}

)} - {futureValue && ( -

{SIGN_HELPER}

- )}
)} @@ -880,7 +869,7 @@ export default function Page() { value={mode} checked={paymentFrequencyMode === mode} onChange={() => setPaymentFrequencyMode(mode)} - className="w-4 h-4 text-primary border-border focus:ring-primary" + className="w-4 h-4 accent-lagunita cursor-pointer" /> {mode === "same" ? "Same as compounding" : "Different"} From 78deb4bc8a8783f8416be54921a20f03407ee686 Mon Sep 17 00:00:00 2001 From: Jen Breese-Kauth Date: Thu, 25 Jun 2026 12:28:20 -0700 Subject: [PATCH 06/32] fixup for 6/25 changes --- .../time-value-money-calculator/page.tsx | 430 +++++++++++------- 1 file changed, 277 insertions(+), 153 deletions(-) diff --git a/app/interactives/time-value-money-calculator/page.tsx b/app/interactives/time-value-money-calculator/page.tsx index 4f77da2..4cc9474 100644 --- a/app/interactives/time-value-money-calculator/page.tsx +++ b/app/interactives/time-value-money-calculator/page.tsx @@ -161,19 +161,19 @@ export default function Page() { 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 $${CONSTRAINTS.presentValue.min.toLocaleString()} and $${CONSTRAINTS.presentValue.max.toLocaleString()}.` }) + 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 $${CONSTRAINTS.futureValue.min.toLocaleString()} and $${CONSTRAINTS.futureValue.max.toLocaleString()}.` }) + 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 $${CONSTRAINTS.payment.min.toLocaleString()} and $${CONSTRAINTS.payment.max.toLocaleString()}.` }) + 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 !== "-") { @@ -303,9 +303,12 @@ export default function Page() { 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) - 1) * 100 - : ratePerPeriodCalc * compFreq * 100 + calculatedValue = + paymentFrequencyMode === "different" && compFreq !== pmtFreq + ? (Math.pow(1 + ratePerPeriodCalc, pmtFreq / compFreq) - 1) * + compFreq * + 100 + : ratePerPeriodCalc * compFreq * 100; break } // Newton-Raphson solve @@ -318,9 +321,12 @@ export default function Page() { ? pv + pmt * n + fv : pv * cf + pmt * ((cf - 1) / guess) * currentTiming + fv if (Math.abs(eq) < 0.0001) { - calculatedRate = paymentFrequencyMode === "different" && compFreq !== pmtFreq - ? (Math.pow(1 + guess, pmtFreq) - 1) * 100 - : guess * compFreq * 100 + calculatedRate = + paymentFrequencyMode === "different" && compFreq !== pmtFreq + ? (Math.pow(1 + guess, pmtFreq / compFreq) - 1) * + compFreq * + 100 + : guess * compFreq * 100; break } const delta = 0.0001 @@ -541,79 +547,6 @@ export default function Page() { const currentOption = SOLVE_OPTIONS.find(o => o.value === solveFor) const currentExample = getExample() - const HowToUseInfoBox = () => ( -
- - - {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}
  • )} -
- - - )} -
-
-
- )} -
- ) - // ── Result panel content ─────────────────────────────────────────────────── const ResultDisplay = ({ @@ -669,16 +602,17 @@ export default function Page() { { - const next = value as SolveFor + const next = value as SolveFor; if (next !== solveFor) { - setPresentValue("") - setFutureValue("") - setPayment("") - setAnnualRate("") - setPeriods("") - setPaymentFrequencyMode("same") + setPresentValue(""); + setFutureValue(""); + setPayment(""); + setAnnualRate(""); + setPeriods(""); + setPaymentFrequencyMode("same"); + setPaymentTiming("end"); } - setSolveFor(next) + setSolveFor(next); }} > @@ -691,32 +625,133 @@ export default function Page() {
- + {/* 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" && (
-
)} @@ -724,23 +759,32 @@ export default function Page() { {/* Payment — early position for RATE/NPER */} {(solveFor === "RATE" || solveFor === "NPER") && (
-
)} @@ -748,55 +792,82 @@ export default function Page() { {/* Future Value */} {solveFor !== "FV" && (
-
)} {/* 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)]" : ""}`} - /> + {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")} +

+ )}
- {getFieldError("payment") && ( -

{getFieldError("payment")}

- )} -
- )} + )} {/* Annual Interest Rate */} {solveFor !== "RATE" && (
-
)} @@ -820,7 +899,10 @@ export default function Page() { {/* Number of Periods */} {solveFor !== "NPER" && (
-
)} {/* Compounding Frequency */}
-