diff --git a/app/interactives/time-value-money-calculator/app.tsx b/app/interactives/time-value-money-calculator/app.tsx index 74628ac..250add7 100644 --- a/app/interactives/time-value-money-calculator/app.tsx +++ b/app/interactives/time-value-money-calculator/app.tsx @@ -64,6 +64,7 @@ export function TVMCalculator() { const [result, setResult] = useState(null) const [calcError, setCalcError] = useState("") const [fieldErrors, setFieldErrors] = useState([]) + const [signError, setSignError] = useState("") const [showHowToUse, setShowHowToUse] = useState(false) const [showExamples, setShowExamples] = useState(false) const [exampleMode, setExampleMode] = useState<"saving" | "borrowing">("saving") @@ -149,8 +150,11 @@ export function TVMCalculator() { 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 = { @@ -189,6 +193,19 @@ export function TVMCalculator() { const timingMultiplier = paymentTiming === "beginning" ? (1 + ratePerPeriod) : 1 try { + // Require opposite signs for RATE/NPER solves: at least one cash flow must have opposite sign + if ((solveFor === "RATE" || solveFor === "NPER")) { + const allPositive = pv > 0 && pmt > 0 && fv > 0 + const allNegative = 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) { @@ -264,10 +281,24 @@ export function TVMCalculator() { case "NPER": { if (ratePerPeriod === 0) { if (pmt === 0 && payment === "0") throw new Error("Payment cannot be 0 when rate is 0") - calculatedValue = pmt === 0 ? 0 : -(pv + fv) / pmt + 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 - calculatedValue = Math.log((pmtAdj - fv * ratePerPeriod) / (pmtAdj + pv * ratePerPeriod)) / Math.log(1 + ratePerPeriod) + 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 } @@ -433,7 +464,8 @@ export function TVMCalculator() { fv: "30000", rate: "3.5", compoundFreq: "12", - paymentFreq: "12", + paymentFreqMode: "different" as PaymentFrequencyMode, + paymentFreq: "26", }, }; } @@ -518,7 +550,7 @@ export function TVMCalculator() { "Number of Periods Example: Solve for time to pay off credit card", bullets: [ "Present value (credit card balance): positive", - "Payment per period (monthly payments): negative", + "Payment per period (bi-weekly payments): negative", "Future value (remaining balance): zero", ], example: { @@ -528,7 +560,7 @@ export function TVMCalculator() { fv: "0", rate: "18", compoundFreq: "365", - paymentFreq: "12", + paymentFreq: "26", paymentFreqMode: "different" as PaymentFrequencyMode, }, }; @@ -566,8 +598,8 @@ export function TVMCalculator() { {showExamples && (
-
- I am: +
+ I am: {(["saving", "borrowing"] as const).map(mode => (
- {getFieldError("presentValue") &&

{getFieldError("presentValue")}

} + {getFieldError("presentValue") &&

{getFieldError("presentValue")}

}
)} @@ -673,7 +706,7 @@ export function TVMCalculator() { onBlur={(e) => handleInputBlur(e.target.value, setPayment)} className={`border-border pl-7 bg-card ${getFieldError("payment") ? "border-destructive" : ""}`} />
- {getFieldError("payment") &&

{getFieldError("payment")}

} + {getFieldError("payment") &&

{getFieldError("payment")}

} )} @@ -688,7 +721,7 @@ export function TVMCalculator() { onBlur={(e) => handleInputBlur(e.target.value, setFutureValue)} className={`border-border pl-7 bg-card ${getFieldError("futureValue") ? "border-destructive" : ""}`} /> - {getFieldError("futureValue") &&

{getFieldError("futureValue")}

} + {getFieldError("futureValue") &&

{getFieldError("futureValue")}

} )} @@ -703,7 +736,7 @@ export function TVMCalculator() { onBlur={(e) => handleInputBlur(e.target.value, setPayment)} className={`border-border pl-7 bg-card ${getFieldError("payment") ? "border-destructive" : ""}`} /> - {getFieldError("payment") &&

{getFieldError("payment")}

} + {getFieldError("payment") &&

{getFieldError("payment")}

} )} @@ -718,7 +751,7 @@ export function TVMCalculator() { className={`border-border pr-8 bg-card ${getFieldError("annualRate") ? "border-destructive" : ""}`} /> % - {getFieldError("annualRate") &&

{getFieldError("annualRate")}

} + {getFieldError("annualRate") &&

{getFieldError("annualRate")}

} )} @@ -729,7 +762,7 @@ export function TVMCalculator() { { const val = e.target.value; if (val === "" || /^\d*$/.test(val)) setPeriods(val) }} className={`border-border bg-card ${getFieldError("periods") ? "border-destructive" : ""}`} /> - {getFieldError("periods") &&

{getFieldError("periods")}

} + {getFieldError("periods") &&

{getFieldError("periods")}

} )} @@ -804,11 +837,11 @@ export function TVMCalculator() { {/* Results Card */} - +

{currentOption?.label}

- {calcError ? ( -

{calcError}

+ {displayError ? ( +

{displayError}

) : result !== null ? (

{formatResult(result)}

) : ( @@ -820,11 +853,11 @@ export function TVMCalculator() { {/* Mobile sticky footer */}
-
+
{currentOption?.label}
- {calcError ? ( -

{calcError}

+ {displayError ? ( +

{displayError}

) : result !== null ? (

{formatResult(result)}

) : (