diff --git a/app/interactives/present-value-calculator-v2/page.tsx b/app/interactives/present-value-calculator-v2/page.tsx index 408d66b..2512a2f 100644 --- a/app/interactives/present-value-calculator-v2/page.tsx +++ b/app/interactives/present-value-calculator-v2/page.tsx @@ -54,15 +54,26 @@ 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) + + // 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 @@ -80,27 +91,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", { @@ -113,286 +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.

-
- -
- {futureValue > 0 && ( - +
+ {/* 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} +

)} - 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 ${ - futureValue > 0 ? "pl-7" : "" - }`} - />
-
- {/* Interest Rate Input */} -
-
-
- {/* Time Period Input */} -
- - 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" - /> -

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

+ {/* Compounding Frequency */} +
+ + +
- {/* Compounding Frequency */} -
- - -
-
+ {/* Results Section */} +
+ {/* Main Present Value Display */} +

+ Present value +

+

+ {formatCurrency(singleCalculations.presentValue)} +

- {/* Results Section */} -
- {/* Main Present Value Display */} -

Present value

-

- {formatCurrency(singleCalculations.presentValue)} -

- - {/* Breakdown */} -
-
-
- Future value: + {/* Breakdown */} +
+
+
+ Future value: +
+
+ {formatCurrency(futureValue)} +
-
- {formatCurrency(futureValue)} +
+
+ Discount amount: +
+
+ {formatCurrency(singleCalculations.discountAmount)} +
-
-
- Present value: -
-
- {formatCurrency(singleCalculations.presentValue)} +
+
+ + + {/* 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} +

+ )}
-
-
- Discount amount: -
-
- {formatCurrency(singleCalculations.discountAmount)} + + {/* 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} +

+ )}
-
-
-
- - - {/* 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.

-
- -
- {paymentAmount > 0 && ( - - $ + + {/* 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} +

)} - 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 ${ - paymentAmount > 0 ? "pl-7" : "" - }`} - />
-
- {/* Interest Rate Input */} -
- -
+ {/* Number of Payments Input */} +
+ setPaymentInterestRate(e.target.value === "" ? 0 : Number(e.target.value))} - min={0} - max={100} - step={0.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" + inputMode="numeric" + 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="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" - /> -

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

+ {/* Payment Frequency */} +
+ + +
- {/* Payment Frequency */} -
- - -
-
+ {/* Results Section */} +
+

+ Present value +

+

+ {formatCurrency(paymentCalculations.presentValue)} +

- {/* Results Section */} -
-

Present value

-

- {formatCurrency(paymentCalculations.presentValue)} -

- - {/* Breakdown */} -
-
-
- Total Payments: -
-
- {formatCurrency(paymentCalculations.totalPayments)} -
-
-
-
- Present Value: + {/* Breakdown */} +
+
+
+ Total payments: +
+
+ {formatCurrency(paymentCalculations.totalPayments)} +
-
- {formatCurrency(paymentCalculations.presentValue)} -
-
-
-
- Discount Amount: -
-
- {formatCurrency(paymentCalculations.discountAmount)} +
+
+ Discount amount: +
+
+ {formatCurrency(paymentCalculations.discountAmount)} +
-
- - + + +
-
- ) + ); } \ No newline at end of file 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: