From 67ed153decc10b195eee07709e0c15a19de68fd8 Mon Sep 17 00:00:00 2001 From: Jen Breese-Kauth Date: Mon, 8 Jun 2026 16:15:35 -0700 Subject: [PATCH 1/2] Time Value of Money client feedback updates --- app/interactives/time-value-money-calculator/app.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/interactives/time-value-money-calculator/app.tsx b/app/interactives/time-value-money-calculator/app.tsx index 74628ac..d3f1658 100644 --- a/app/interactives/time-value-money-calculator/app.tsx +++ b/app/interactives/time-value-money-calculator/app.tsx @@ -433,7 +433,8 @@ export function TVMCalculator() { fv: "30000", rate: "3.5", compoundFreq: "12", - paymentFreq: "12", + paymentFreqMode: "different" as PaymentFrequencyMode, + paymentFreq: "26", }, }; } @@ -518,7 +519,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 +529,7 @@ export function TVMCalculator() { fv: "0", rate: "18", compoundFreq: "365", - paymentFreq: "12", + paymentFreq: "26", paymentFreqMode: "different" as PaymentFrequencyMode, }, }; @@ -625,6 +626,7 @@ export function TVMCalculator() { onClick={() => { if (option.value !== solveFor) { setPresentValue(""); setFutureValue(""); setPayment(""); setAnnualRate(""); setPeriods("") + setPaymentFrequencyMode("same") } setSolveFor(option.value) }} @@ -804,7 +806,7 @@ export function TVMCalculator() { {/* Results Card */} - +

{currentOption?.label}

{calcError ? ( @@ -820,7 +822,7 @@ export function TVMCalculator() { {/* Mobile sticky footer */}
-
+
{currentOption?.label}
{calcError ? ( From c43cce186385e316f74cfe7c2c7eeba3ffcc706a Mon Sep 17 00:00:00 2001 From: Jen Breese-Kauth Date: Tue, 9 Jun 2026 15:09:49 -0700 Subject: [PATCH 2/2] Fixups to questions and added errors --- .../time-value-money-calculator/app.tsx | 65 ++++++++++++++----- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/app/interactives/time-value-money-calculator/app.tsx b/app/interactives/time-value-money-calculator/app.tsx index d3f1658..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 } @@ -567,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")}

}
)} @@ -675,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")}

}
)} @@ -690,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")}

}
)} @@ -705,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")}

} )} @@ -720,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")}

} )} @@ -731,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")}

} )} @@ -809,8 +840,8 @@ export function TVMCalculator() {

{currentOption?.label}

- {calcError ? ( -

{calcError}

+ {displayError ? ( +

{displayError}

) : result !== null ? (

{formatResult(result)}

) : ( @@ -825,8 +856,8 @@ export function TVMCalculator() {
{currentOption?.label}
- {calcError ? ( -

{calcError}

+ {displayError ? ( +

{displayError}

) : result !== null ? (

{formatResult(result)}

) : (