From 28ee6fac01897b490be55689a98fe4b0313b749c Mon Sep 17 00:00:00 2001 From: Jen Breese-Kauth Date: Mon, 18 May 2026 16:08:28 -0700 Subject: [PATCH 1/3] IFDM-184: Design feedback and additional field --- .../present-value-calculator-v2/page.tsx | 143 ++++++++++-------- app/lib/theme-toggle.tsx | 11 +- 2 files changed, 85 insertions(+), 69 deletions(-) diff --git a/app/interactives/present-value-calculator-v2/page.tsx b/app/interactives/present-value-calculator-v2/page.tsx index 408d66b..a4b0286 100644 --- a/app/interactives/present-value-calculator-v2/page.tsx +++ b/app/interactives/present-value-calculator-v2/page.tsx @@ -54,15 +54,16 @@ export default function PresentValueCalculator() { // Single Amount State const [futureValue, setFutureValue] = useState(0) - const [interestRate, setInterestRate] = useState(5) - const [timePeriod, setTimePeriod] = useState(10) + const [interestRate, setInterestRate] = useState(0) + const [timePeriod, setTimePeriod] = useState(0) const [compoundingFrequency, setCompoundingFrequency] = useState("annually") // Payment Series State const [paymentAmount, setPaymentAmount] = useState(0) - const [paymentInterestRate, setPaymentInterestRate] = useState(5) - const [numberOfPayments, setNumberOfPayments] = useState(10) + const [paymentInterestRate, setPaymentInterestRate] = useState(0) + const [numberOfPayments, setNumberOfPayments] = useState(0) const [paymentFrequency, setPaymentFrequency] = useState("annually") + const [finalAmount, setFinalAmount] = useState(0) const singleCalculations = useMemo(() => { const rate = interestRate / 100 @@ -80,27 +81,39 @@ export default function PresentValueCalculator() { }, [futureValue, interestRate, timePeriod, compoundingFrequency]) const paymentCalculations = useMemo(() => { - const rate = paymentInterestRate / 100 - const n = frequencyMap[paymentFrequency].periods - const periodRate = rate / n - - // Present value of an annuity formula: PV = PMT * [(1 - (1 + r)^-n) / r] - let presentValue: number + const rate = paymentInterestRate / 100; + const n = frequencyMap[paymentFrequency].periods; + const periodRate = rate / n; + + let pvPayments: number; if (periodRate === 0) { - presentValue = paymentAmount * numberOfPayments + pvPayments = paymentAmount * numberOfPayments; } else { - presentValue = paymentAmount * ((1 - Math.pow(1 + periodRate, -numberOfPayments)) / periodRate) + pvPayments = + paymentAmount * + ((1 - Math.pow(1 + periodRate, -numberOfPayments)) / periodRate); } - - const totalPayments = paymentAmount * numberOfPayments - const discountAmount = totalPayments - presentValue + + // PV of the lump sum final amount discounted over all periods + const pvFinalAmount = + finalAmount / Math.pow(1 + periodRate, numberOfPayments); + + const presentValue = pvPayments + pvFinalAmount; + const totalPayments = paymentAmount * numberOfPayments + finalAmount; + const discountAmount = totalPayments - presentValue; return { presentValue, totalPayments, discountAmount, - } - }, [paymentAmount, paymentInterestRate, numberOfPayments, paymentFrequency]) + }; + }, [ + paymentAmount, + finalAmount, + paymentInterestRate, + numberOfPayments, + paymentFrequency, + ]); const formatCurrency = (value: number) => { return new Intl.NumberFormat("en-US", { @@ -136,17 +149,16 @@ export default function PresentValueCalculator() { Future value
- {futureValue > 0 && ( - - $ - - )} + setFutureValue(e.target.value === "" ? 0 : Number(e.target.value))} - className={`bg-white border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${ + className={`border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${ futureValue > 0 ? "pl-7" : "" }`} /> @@ -157,19 +169,21 @@ export default function PresentValueCalculator() {
setInterestRate(e.target.value === "" ? 0 : Number(e.target.value))} min={0} max={100} step={0.1} - 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" + className="border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> - +
@@ -183,16 +197,14 @@ export default function PresentValueCalculator() { setTimePeriod(e.target.value === "" ? 0 : Number(e.target.value))} min={1} max={1000} step={1} - className="bg-white border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + className="border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> -

- {timePeriod > 0 && `${formatNumber(timePeriod)} ${frequencyMap[compoundingFrequency].label.toLowerCase()} period${timePeriod !== 1 ? 's' : ''} = ${formatTimePeriod(timePeriod, frequencyMap[compoundingFrequency].periods)}`} -

{/* Compounding Frequency */} @@ -225,7 +237,7 @@ export default function PresentValueCalculator() {

{/* Breakdown */} -
+
Future value: @@ -234,14 +246,6 @@ export default function PresentValueCalculator() { {formatCurrency(futureValue)}
-
-
- Present value: -
-
- {formatCurrency(singleCalculations.presentValue)} -
-
Discount amount: @@ -264,26 +268,47 @@ export default function PresentValueCalculator() {

Find what a series of payments is worth today. Enter a payment amount and number of payments to calculate the present value.

- {paymentAmount > 0 && ( - - $ - - )} + setPaymentAmount(e.target.value === "" ? 0 : Number(e.target.value))} - className={`bg-white border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${ + className={`border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${ paymentAmount > 0 ? "pl-7" : "" }`} />
+ {/* Final Amount Input */} +
+ +
+ + setFinalAmount(e.target.value === "" ? 0 : Number(e.target.value))} + className={`border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${ + finalAmount > 0 ? "pl-7" : "" + }`} + /> +
+
+ {/* Interest Rate Input */}
{/* Payment Frequency */} @@ -359,7 +384,7 @@ export default function PresentValueCalculator() {
- Total Payments: + Total payments:
@@ -369,17 +394,7 @@ export default function PresentValueCalculator() {
- Present Value: -
-
- {formatCurrency(paymentCalculations.presentValue)} -
-
-
-
- Discount Amount: + Discount amount:
diff --git a/app/lib/theme-toggle.tsx b/app/lib/theme-toggle.tsx index 232d18e..c7ae5e4 100644 --- a/app/lib/theme-toggle.tsx +++ b/app/lib/theme-toggle.tsx @@ -4,8 +4,8 @@ import { useEffect, useState } from "react"; import { FiSun, FiMoon } from "react-icons/fi"; const themes = [ - { label: "Light", value: "light", icon: }, - { label: "Dark", value: "dark", icon: }, + { label: "Light", value: "light", icon:
@@ -374,8 +374,8 @@ export default function PresentValueCalculator() { {/* Results Section */}
-

Present value

-

+

Present value

+

{formatCurrency(paymentCalculations.presentValue)}

From a545456976221dd0617589aaeff60e1ddbc38f4a Mon Sep 17 00:00:00 2001 From: Jen Breese-Kauth Date: Wed, 20 May 2026 16:47:03 -0700 Subject: [PATCH 3/3] error states --- .../present-value-calculator-v2/page.tsx | 693 +++++++++++------- app/ui/globals.css | 5 + 2 files changed, 450 insertions(+), 248 deletions(-) diff --git a/app/interactives/present-value-calculator-v2/page.tsx b/app/interactives/present-value-calculator-v2/page.tsx index 05ad030..2512a2f 100644 --- a/app/interactives/present-value-calculator-v2/page.tsx +++ b/app/interactives/present-value-calculator-v2/page.tsx @@ -65,6 +65,16 @@ export default function PresentValueCalculator() { const [paymentFrequency, setPaymentFrequency] = useState("annually") const [finalAmount, setFinalAmount] = useState(0) + // Error states + const [futureValueError, setFutureValueError] = useState(""); + const [interestRateError, setInterestRateError] = useState(""); + const [timePeriodError, setTimePeriodError] = useState(""); + const [paymentAmountError, setPaymentAmountError] = useState(""); + const [finalAmountError, setFinalAmountError] = useState(""); + const [paymentInterestRateError, setPaymentInterestRateError] = + useState(""); + const [numberOfPaymentsError, setNumberOfPaymentsError] = useState(""); + const singleCalculations = useMemo(() => { const rate = interestRate / 100 const n = frequencyMap[compoundingFrequency].periods @@ -126,288 +136,475 @@ export default function PresentValueCalculator() { return (
-
- {/* Header */} -

Present Value Calculator

- -
- - - - Single amount - Payment series - - - {/* Single Amount Tab */} - -
-
- {/* Future Value Input */} -

Enter a future value to calculate what it is worth today.

-
- -
- - setFutureValue(e.target.value === "" ? 0 : Number(e.target.value))} - className={`border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${ - futureValue > 0 ? "pl-7" : "pl-8" - }`} - /> +
+ {/* Header */} +

Present Value Calculator

+ +
+ + + Single amount + Payment series + + + {/* Single Amount Tab */} + +
+
+ {/* Future Value Input */} +

+ Enter a future value to calculate what it is worth today. +

+
+ +
+ + { + const val = + e.target.value === "" ? 0 : Number(e.target.value); + if (val < 0) { + setFutureValueError( + "Future value must be greater than 0.", + ); + return; + } + setFutureValueError(""); + setFutureValue(val); + }} + className={`border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${futureValue > 0 ? "pl-7" : "pl-8"} ${futureValueError ? "border-[var(--color-inline-error)] border-2" : ""}`} + /> +
+ {futureValueError && ( +

+ {futureValueError} +

+ )}
-
- {/* Interest Rate Input */} -
-
-
- {/* Time Period Input */} -
- - setTimePeriod(e.target.value === "" ? 0 : Number(e.target.value))} - min={1} - max={1000} - step={1} - className="border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> + {/* Compounding Frequency */} +
+ + +
- {/* Compounding Frequency */} -
- - -
-
- - {/* Results Section */} -
- {/* Main Present Value Display */} -

Present value

-

- {formatCurrency(singleCalculations.presentValue)} -

- - {/* Breakdown */} -
-
-
- Future value: +
+
+ Future value: +
+
+ {formatCurrency(futureValue)} +
-
- {formatCurrency(futureValue)} -
-
-
-
- Discount amount: -
-
- {formatCurrency(singleCalculations.discountAmount)} +
+
+ Discount amount: +
+
+ {formatCurrency(singleCalculations.discountAmount)} +
-
- - - {/* Payment Series Tab */} - -
- -
- {/* Payment Amount Input */} -

Find what a series of payments is worth today. Enter a payment amount and number of payments to calculate the present value.

-
- -
- - setPaymentAmount(e.target.value === "" ? 0 : Number(e.target.value))} - className={`border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${ - paymentAmount > 0 ? "pl-7" : "pl-8" - }`} - /> + + + {/* Payment Series Tab */} + +
+
+ {/* Payment Amount Input */} +

+ Find what a series of payments is worth today. Enter a + payment amount and number of payments to calculate the + present value. +

+
+ +
+ + { + const val = + e.target.value === "" ? 0 : Number(e.target.value); + if (val < 0) { + setPaymentAmountError( + "Payment amount must be greater than 0.", + ); + return; + } + setPaymentAmountError(""); + setPaymentAmount(val); + }} + className={`border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${paymentAmount > 0 ? "pl-7" : "pl-8"} ${paymentAmountError ? "border-[var(--color-inline-error)] border-2" : ""}`} + /> +
+ {paymentAmountError && ( +

+ {paymentAmountError} +

+ )}
-
- {/* Final Amount Input */} -
- -
- - setFinalAmount(e.target.value === "" ? 0 : Number(e.target.value))} - className={`border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${ - finalAmount > 0 ? "pl-7" : "pl-8" - }`} - /> + {/* Final Amount Input */} +
+ +
+ + { + const val = + e.target.value === "" ? 0 : Number(e.target.value); + if (val < 0) { + setFinalAmountError( + "Final amount cannot be negative.", + ); + return; + } + setFinalAmountError(""); + setFinalAmount(val); + }} + className={`border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${finalAmount > 0 ? "pl-7" : "pl-8"} ${finalAmountError ? "border-[var(--color-inline-error)] border-2" : ""}`} + /> +
+ {finalAmountError && ( +

+ {finalAmountError} +

+ )} +
+ + {/* Interest Rate Input */} +
+ +
+ { + const val = + e.target.value === "" ? 0 : Number(e.target.value); + if (val > 100) { + setPaymentInterestRateError( + "Annual interest rate cannot exceed 100%.", + ); + } else { + setPaymentInterestRateError(""); + setPaymentInterestRate(val); + } + }} + className={`border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${paymentInterestRateError ? "border-[var(--color-inline-error)] border-2" : ""}`} + min={0} + max={100} + step={0.1} + /> + +
+ {paymentInterestRateError && ( +

+ {paymentInterestRateError} +

+ )}
-
- {/* Interest Rate Input */} -
- -
+ {/* Number of Payments Input */} +
+ setPaymentInterestRate(e.target.value === "" ? 0 : Number(e.target.value))} - min={0} - max={100} - step={0.1} - className="border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + value={numberOfPayments === 0 ? "" : numberOfPayments} + onChange={(e) => { + const val = + e.target.value === "" ? 0 : Number(e.target.value); + if (val < 1) { + setNumberOfPaymentsError( + "Number of payments must be greater than 0.", + ); + return; + } + setNumberOfPaymentsError(""); + setNumberOfPayments(val); + }} + className={`border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${numberOfPaymentsError ? "border-[var(--color-inline-error)] border-2" : ""}`} + min={1} + max={1000} + step={1} /> - + {numberOfPaymentsError && ( +

+ {numberOfPaymentsError} +

+ )}
-
- {/* Number of Payments Input */} -
- - setNumberOfPayments(e.target.value === "" ? 0 : Number(e.target.value))} - min={1} - max={1000} - step={1} - className="border-1 w-full rounded-md shadow-sm py-2 px-3 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> -
- - {/* Payment Frequency */} -
- - + {/* Payment Frequency */} +
+ + +
-
- {/* Results Section */} -
-

Present value

-

- {formatCurrency(paymentCalculations.presentValue)} -

- - {/* Breakdown */} -
-
-
- Total payments: -
-
- {formatCurrency(paymentCalculations.totalPayments)} + {/* Results Section */} +
+

+ Present value +

+

+ {formatCurrency(paymentCalculations.presentValue)} +

+ + {/* Breakdown */} +
+
+
+ Total payments: +
+
+ {formatCurrency(paymentCalculations.totalPayments)} +
-
-
-
- Discount amount: -
-
- {formatCurrency(paymentCalculations.discountAmount)} +
+
+ Discount amount: +
+
+ {formatCurrency(paymentCalculations.discountAmount)} +
-
- - + + +
-
- ) + ); } \ No newline at end of file diff --git a/app/ui/globals.css b/app/ui/globals.css index f0c916f..2974625 100644 --- a/app/ui/globals.css +++ b/app/ui/globals.css @@ -119,6 +119,9 @@ --foreground: oklch(0.145 0 0); --button-green: rgba(30, 113, 102, 1); --button-berry: rgba(151, 24, 87, 1); + --color-teal: rgba(74, 203, 225, 1); + --color-symbols: #616877; + --color-inline-error: #8C1515; } .dark { @@ -171,6 +174,8 @@ --additional-background: rgb(52, 51, 51); --button-green: rgba(30, 113, 102, 1); --button-berry: rgba(151, 24, 87, 1); + --color-symbols: oklch(0.985 0 0); + --color-inline-error: #F4795B; } @layer base {