diff --git a/app/interactives/retirement-calculator/page.tsx b/app/interactives/retirement-calculator/page.tsx index d66697b..f1b876b 100644 --- a/app/interactives/retirement-calculator/page.tsx +++ b/app/interactives/retirement-calculator/page.tsx @@ -37,16 +37,25 @@ const defaultInputs: CalculatorInputs = { yearsToRetirement: 0, } +const baseInputClass = "w-full py-3 border-2 rounded-lg outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" +const inputStateClass = (error?: string, warning?: string) => + error + ? "border-[var(--color-inline-error)] focus:border-[var(--color-inline-error)] focus:ring-2 focus:ring-[var(--color-inline-error)]/20" + : warning + ? "border-[var(--color-inline-warning)] focus:border-[var(--color-inline-warning)] focus:ring-2 focus:ring-[var(--color-inline-warning)]/20" + : "border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-200" + export default function RetirementCalculator() { const [activeTab, setActiveTab] = useState<"balance" | "savings">("balance") const [inputs, setInputs] = useState(defaultInputs) const [frozenRequiredBalance, setFrozenRequiredBalance] = useState(0) + const [errors, setErrors] = useState>>({}) + const [warnings, setWarnings] = useState>>({}) + const [touched, setTouched] = useState>>({}) - // Fix 1: Derive requiredBalance directly for balance tab display, - // separate from the frozen value used in savings tab const calculatedRequiredBalance = useMemo(() => { const rate = inputs.expectedReturnDuringRetirement / 100 - if (rate <= 0) { + if (rate === 0) { return inputs.annualSpending * inputs.retirementLength } return ( @@ -55,22 +64,31 @@ export default function RetirementCalculator() { ) }, [inputs.annualSpending, inputs.retirementLength, inputs.expectedReturnDuringRetirement]) - // Show balance results only when all three balance inputs are filled const showBalanceResults = inputs.annualSpending > 0 && - inputs.retirementLength > 0 + inputs.retirementLength > 0 && + !errors.annualSpending && + !errors.retirementLength && + !errors.expectedReturnDuringRetirement + + const showSavingsResults = + frozenRequiredBalance > 0 && + inputs.yearsToRetirement > 0 && + inputs.expectedReturnBeforeRetirement !== 0 && + !errors.currentSavings && + !errors.yearsToRetirement && + !errors.expectedReturnBeforeRetirement const results = useMemo(() => { const realReturnBeforeRetirement = inputs.expectedReturnBeforeRetirement / 100 - // Fix 2: Use frozen value for savings tab, fresh calculation for balance tab const requiredBalance = activeTab === "savings" ? frozenRequiredBalance : calculatedRequiredBalance const FV_currentSavings = inputs.currentSavings * Math.pow(1 + realReturnBeforeRetirement, inputs.yearsToRetirement) const targetBalance = Math.max(0, requiredBalance - FV_currentSavings) let annualSavings: number - if (realReturnBeforeRetirement <= 0 || inputs.yearsToRetirement <= 0) { + if (realReturnBeforeRetirement === 0 || inputs.yearsToRetirement <= 0) { annualSavings = inputs.yearsToRetirement > 0 ? targetBalance / inputs.yearsToRetirement : 0 } else { annualSavings = @@ -79,7 +97,7 @@ export default function RetirementCalculator() { } const calculateFrequencySavings = (frequency: number) => { - if (realReturnBeforeRetirement <= 0 || inputs.yearsToRetirement <= 0) { + if (realReturnBeforeRetirement === 0 || inputs.yearsToRetirement <= 0) { return inputs.yearsToRetirement > 0 ? targetBalance / (inputs.yearsToRetirement * frequency) : 0 } const periodRate = Math.pow(1 + realReturnBeforeRetirement, 1 / frequency) - 1 @@ -104,6 +122,72 @@ export default function RetirementCalculator() { const updateInput = (key: keyof CalculatorInputs, value: string) => { const numValue = parseFloat(value) || 0 + + if (key === "annualSpending") { + if (numValue !== 0 && (numValue < 1 || numValue > 10_000_000)) { + setErrors((prev) => ({ ...prev, annualSpending: "Enter an amount between $1 and $10,000,000." })) + } else { + setErrors((prev) => ({ ...prev, annualSpending: undefined })) + } + } + + if (key === "currentSavings") { + if (numValue < 0 || numValue > 100_000_000) { + setErrors((prev) => ({ ...prev, currentSavings: "Enter an amount between $0 and $100,000,000." })) + } else { + setErrors((prev) => ({ ...prev, currentSavings: undefined })) + } + } + + if (key === "expectedReturnBeforeRetirement") { + if (numValue < -5 || numValue > 30) { + setErrors((prev) => ({ ...prev, expectedReturnBeforeRetirement: "Enter a rate between −5% and 30%." })) + setWarnings((prev) => ({ ...prev, expectedReturnBeforeRetirement: undefined })) + } else if (numValue >= -5 && numValue < 0) { + setErrors((prev) => ({ ...prev, expectedReturnBeforeRetirement: undefined })) + setWarnings((prev) => ({ ...prev, expectedReturnBeforeRetirement: "A negative return means your savings are expected to lose value over time. Double-check your entry." })) + } else { + setErrors((prev) => ({ ...prev, expectedReturnBeforeRetirement: undefined })) + setWarnings((prev) => ({ ...prev, expectedReturnBeforeRetirement: undefined })) + } + } + + if (key === "yearsToRetirement") { + if (numValue < 0 || numValue > 75) { + setErrors((prev) => ({ ...prev, yearsToRetirement: "Enter an amount of years between 0 and 75." })) + } else if (numValue === 0 && value !== "") { + setErrors((prev) => ({ ...prev, yearsToRetirement: "With no time left to save, you'd need your full target balance today." })) + } else if (numValue > 0) { + setErrors((prev) => ({ ...prev, yearsToRetirement: undefined })) + } + } + + if (key === "expectedReturnDuringRetirement") { + if (numValue !== 0 && (numValue < -5 || numValue > 30)) { + setErrors((prev) => ({ ...prev, expectedReturnDuringRetirement: "Enter a rate between −5% and 30%." })) + } else { + setErrors((prev) => ({ ...prev, expectedReturnDuringRetirement: undefined })) + } + if (numValue >= -5 && numValue < 0) { + setWarnings((prev) => ({ ...prev, expectedReturnDuringRetirement: "A negative return means your savings are expected to lose value over time. Double-check your entry." })) + } else { + setWarnings((prev) => ({ ...prev, expectedReturnDuringRetirement: undefined })) + } + } + + if (key === "retirementLength") { + if (numValue !== 0 && (numValue < 1 || numValue > 75)) { + setErrors((prev) => ({ ...prev, retirementLength: "Enter an amount of years between 1 and 75." })) + } else { + setErrors((prev) => ({ ...prev, retirementLength: undefined })) + } + if (numValue >= 1 && numValue <= 3) { + setWarnings((prev) => ({ ...prev, retirementLength: "This is a very short retirement. Double-check your entry." })) + } else { + setWarnings((prev) => ({ ...prev, retirementLength: undefined })) + } + } + setInputs((prev) => ({ ...prev, [key]: numValue })) const balanceInputs: Array = [ @@ -113,13 +197,12 @@ export default function RetirementCalculator() { ] if (balanceInputs.includes(key)) { - // Fix 3: Correct stale closure — always use the right new vs old values const spendingVal = key === "annualSpending" ? numValue : inputs.annualSpending const lengthVal = key === "retirementLength" ? numValue : inputs.retirementLength const rateVal = key === "expectedReturnDuringRetirement" ? numValue / 100 : inputs.expectedReturnDuringRetirement / 100 let calculatedFrozen: number - if (rateVal <= 0) { + if (rateVal === 0) { calculatedFrozen = spendingVal * lengthVal } else { calculatedFrozen = @@ -133,6 +216,9 @@ export default function RetirementCalculator() { const handleReset = () => { setInputs(defaultInputs) + setErrors({}) + setWarnings({}) + setTouched({}) setActiveTab("balance") setFrozenRequiredBalance(0) } @@ -141,7 +227,6 @@ export default function RetirementCalculator() {
- {/* Fix 6: proper h1 */}

Retirement Planning Calculator

- {/* Fix 1a: htmlFor linked to input id */} @@ -191,19 +275,26 @@ export default function RetirementCalculator() {
- {/* Fix 2: aria-hidden on decorative symbol */} updateInput("annualSpending", e.target.value.replace(/,/g, ""))} - className="w-full pl-8 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={`${baseInputClass} pl-8 pr-16 ${inputStateClass(errors.annualSpending)}`} />
-

How much you plan to withdraw each year.

+ {errors.annualSpending ? ( + + ) : ( +

How much you plan to withdraw each year.

+ )}
{/* Retirement Length */} @@ -216,16 +307,34 @@ export default function RetirementCalculator() { id="retirement-length" type="number" min="1" + max="75" + aria-describedby={ + errors.retirementLength + ? "retirement-length-error" + : warnings.retirementLength + ? "retirement-length-warning" + : undefined + } + aria-invalid={!!errors.retirementLength} value={inputs.retirementLength || ""} onChange={(e) => updateInput("retirementLength", e.target.value)} - 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={`${baseInputClass} pl-4 pr-16 ${inputStateClass(errors.retirementLength, warnings.retirementLength)}`} /> - {/* Fix 3: aria-hidden on decorative suffix */}
-

How many years your retirement will last.

+ {errors.retirementLength ? ( + + ) : warnings.retirementLength ? ( +

+ {warnings.retirementLength} +

+ ) : ( +

How many years your retirement will last.

+ )} {/* Expected Return During Retirement */} @@ -245,15 +354,34 @@ export default function RetirementCalculator() { updateInput("expectedReturnDuringRetirement", e.target.value)} - 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={`${baseInputClass} pl-4 pr-16 ${inputStateClass(errors.expectedReturnDuringRetirement, warnings.expectedReturnDuringRetirement)}`} /> -

Annual investment return rate during retirement.

+ {errors.expectedReturnDuringRetirement ? ( + + ) : warnings.expectedReturnDuringRetirement ? ( +

+ {warnings.expectedReturnDuringRetirement} +

+ ) : ( +

Annual investment return rate during retirement.

+ )} ) : ( @@ -285,12 +413,20 @@ export default function RetirementCalculator() { inputMode="numeric" min="0" placeholder="" + aria-describedby={errors.currentSavings ? "current-savings-error" : undefined} + aria-invalid={!!errors.currentSavings} value={inputs.currentSavings ? formatNumberWithCommas(inputs.currentSavings.toString()) : ""} onChange={(e) => updateInput("currentSavings", e.target.value.replace(/,/g, ""))} - className="w-full pl-8 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={`${baseInputClass} pl-8 pr-16 ${inputStateClass(errors.currentSavings)}`} /> -

How much you have already saved for retirement.

+ {errors.currentSavings ? ( + + ) : ( +

How much you have already saved for retirement.

+ )} {/* Years to Retirement */} @@ -303,16 +439,35 @@ export default function RetirementCalculator() { id="years-to-retirement" type="number" min="0" - max="99" + max="75" + aria-describedby={touched.yearsToRetirement && errors.yearsToRetirement ? "years-to-retirement-error" : undefined} + aria-invalid={touched.yearsToRetirement && !!errors.yearsToRetirement} value={inputs.yearsToRetirement || ""} - onChange={(e) => updateInput("yearsToRetirement", e.target.value)} - 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" + onChange={(e) => { + setTouched((prev) => ({ ...prev, yearsToRetirement: true })) + updateInput("yearsToRetirement", e.target.value) + }} + onBlur={(e) => { + setTouched((prev) => ({ ...prev, yearsToRetirement: true })) + if (e.target.value === "") { + setErrors((prev) => ({ ...prev, yearsToRetirement: "Please enter how many years until you plan to retire." })) + } else if (inputs.yearsToRetirement === 0) { + setErrors((prev) => ({ ...prev, yearsToRetirement: "With no time left to save, you'd need your full target balance today." })) + } + }} + className={`${baseInputClass} pl-4 pr-16 ${touched.yearsToRetirement ? inputStateClass(errors.yearsToRetirement) : "border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"}`} /> -

How many years until you plan to retire.

+ {touched.yearsToRetirement && errors.yearsToRetirement ? ( + + ) : ( +

How many years until you plan to retire.

+ )} {/* Expected Return Before Retirement */} @@ -332,20 +487,48 @@ export default function RetirementCalculator() { updateInput("expectedReturnBeforeRetirement", e.target.value)} - 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" + onChange={(e) => { + setTouched((prev) => ({ ...prev, expectedReturnBeforeRetirement: true })) + updateInput("expectedReturnBeforeRetirement", e.target.value) + }} + onBlur={(e) => { + setTouched((prev) => ({ ...prev, expectedReturnBeforeRetirement: true })) + if (e.target.value === "") { + setErrors((prev) => ({ ...prev, expectedReturnBeforeRetirement: "Please enter an expected annual return rate before retirement." })) + } + }} + className={`${baseInputClass} pl-4 pr-16 ${touched.expectedReturnBeforeRetirement ? inputStateClass(errors.expectedReturnBeforeRetirement, warnings.expectedReturnBeforeRetirement) : warnings.expectedReturnBeforeRetirement ? inputStateClass(undefined, warnings.expectedReturnBeforeRetirement) : "border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"}`} /> -

Annual investment return rate before retirement.

+ {touched.expectedReturnBeforeRetirement && errors.expectedReturnBeforeRetirement ? ( + + ) : warnings.expectedReturnBeforeRetirement ? ( +

+ {warnings.expectedReturnBeforeRetirement} +

+ ) : ( +

Annual investment return rate before retirement.

+ )} )} - {/* Reset Button — Fix 4: type="button" */} + {/* Reset Button */}
+ ) : frozenRequiredBalance > 0 ? ( + <> +

Required Annual Savings

+

+ ) : null} )} diff --git a/app/ui/globals.css b/app/ui/globals.css index 061b3ac..d5bc9bd 100644 --- a/app/ui/globals.css +++ b/app/ui/globals.css @@ -123,6 +123,7 @@ --color-teal: rgba(0, 124, 146, 1); --color-symbols: #616877; --color-inline-error: #8C1515; + --color-inline-warning: #C74632; --results-card-empty: rgba(102, 112, 133, 1); --info-popup-background: rgba(239, 255, 252, 1); } @@ -181,6 +182,7 @@ --color-teal: rgba(74, 203, 225, 1); --color-symbols: oklch(0.985 0 0); --color-inline-error: #F4795B; + --color-inline-warning: #E98300; --results-card-empty: oklch(0.985 0 0); --info-popup-background: rgba(35, 35, 35, 1); }