diff --git a/app/interactives/savings-calculator/lib/calculations.ts b/app/interactives/savings-calculator/lib/calculations.ts index fe8b1f5..f47cc6c 100644 --- a/app/interactives/savings-calculator/lib/calculations.ts +++ b/app/interactives/savings-calculator/lib/calculations.ts @@ -71,21 +71,25 @@ export function calculateYearlyBreakdown( let balance = currentBalance; const totalYears = Math.ceil(totalPeriods / periodsPerYear); - for (let year = 1; year <= totalYears; year++) { + for (let yearNumber = 1; yearNumber <= totalYears; yearNumber++) { const startingBalance = balance; - const periodsInThisYear = Math.min(periodsPerYear, totalPeriods - (year - 1) * periodsPerYear); + const periodsInThisYear = Math.min( + periodsPerYear, + totalPeriods - (yearNumber - 1) * periodsPerYear + ); let yearlyContributions = 0; let yearlyInterest = 0; const futureValueOfInitial = startingBalance * Math.pow(1 + ratePerPeriod, periodsInThisYear); - const futureValueOfAnnuity = - contribution * ((Math.pow(1 + ratePerPeriod, periodsInThisYear) - 1) / ratePerPeriod); + const futureValueOfAnnuity = ratePerPeriod === 0 + ? contribution * periodsInThisYear + : contribution * ((Math.pow(1 + ratePerPeriod, periodsInThisYear) - 1) / ratePerPeriod); balance = futureValueOfInitial + futureValueOfAnnuity; yearlyContributions = contribution * periodsInThisYear; yearlyInterest = balance - (startingBalance + yearlyContributions); breakdown.push({ - year, + year: Math.min(yearNumber, totalPeriods / periodsPerYear), startingBalance, contributions: yearlyContributions, interestEarned: yearlyInterest, @@ -129,29 +133,52 @@ export function calculateMonthySavings( const remainingAmount = savingsGoal - futureValueOfInitial; let results: CalculationResults; + let breakdownPeriods = totalPeriods; + let breakdownContribution = 0; + let finalPartialContribution = 0; if (remainingAmount <= 0) { + const periodsToGoal = + currentBalance < savingsGoal && ratePerPeriod > 0 + ? Math.log(savingsGoal / currentBalance) / Math.log(1 + ratePerPeriod) + : 0; + const monthsToGoal = Math.ceil(periodsToGoal * (12 / periodsPerYear) - 1e-10); + breakdownPeriods = periodsToGoal; + results = { contributionPerPeriod: 0, totalDeposited: currentBalance, interestEarned: savingsGoal - currentBalance, finalBalance: savingsGoal, - timeInMonths: totalTimeInMonths, + timeInMonths: monthsToGoal, + }; + } else if (totalPeriods <= 0) { + results = { + contributionPerPeriod: NaN, + totalDeposited: NaN, + interestEarned: NaN, + finalBalance: NaN, + timeInMonths: NaN, }; } else if (totalPeriods < 1) { - const requiredContributionPerPeriod = savingsGoal - currentBalance; + // Let the existing balance compound through the partial period, then make + // one contribution at the selected horizon to close the remaining gap. + const requiredContributionPerPeriod = remainingAmount; const totalDeposited = currentBalance + requiredContributionPerPeriod; + finalPartialContribution = requiredContributionPerPeriod; results = { contributionPerPeriod: requiredContributionPerPeriod, totalDeposited: totalDeposited, - interestEarned: 0, + interestEarned: savingsGoal - totalDeposited, finalBalance: savingsGoal, timeInMonths: totalTimeInMonths, }; } else { - const requiredContributionPerPeriod = - remainingAmount / ((Math.pow(1 + ratePerPeriod, totalPeriods) - 1) / ratePerPeriod); + const requiredContributionPerPeriod = ratePerPeriod === 0 + ? remainingAmount / totalPeriods + : remainingAmount / ((Math.pow(1 + ratePerPeriod, totalPeriods) - 1) / ratePerPeriod); const totalDeposited = currentBalance + requiredContributionPerPeriod * totalPeriods; + breakdownContribution = requiredContributionPerPeriod; results = { contributionPerPeriod: requiredContributionPerPeriod, totalDeposited: totalDeposited, @@ -162,13 +189,22 @@ export function calculateMonthySavings( } const breakdown = calculateYearlyBreakdown( - results.contributionPerPeriod, - totalPeriods, + breakdownContribution, + breakdownPeriods, currentBalance, compounding, interestRate ); + if (finalPartialContribution > 0 && breakdown.length > 0) { + const finalRow = breakdown[breakdown.length - 1]; + breakdown[breakdown.length - 1] = { + ...finalRow, + contributions: finalPartialContribution, + endingBalance: savingsGoal, + }; + } + return { results, breakdown }; } @@ -253,6 +289,8 @@ export function calculateTimeToGoal( const { periodsPerYear, ratePerPeriod } = getCompoundingParams(compounding, interestRate); let results: CalculationResults; + let periodsForBreakdown = 0; + let finalContributionReduction = 0; if (contributionPerPeriod === 0) { // Interest-only growth (no contributions) @@ -264,34 +302,63 @@ export function calculateTimeToGoal( finalBalance: savingsGoal, timeInMonths: 0, }; + } else if (ratePerPeriod === 0) { + // No interest and no contributions: impossible to reach goal + results = { + contributionPerPeriod: 0, + totalDeposited: currentBalance, + interestEarned: 0, + finalBalance: currentBalance, + timeInMonths: NaN, + }; } else { // Use compound interest formula: n = log(FV/PV) / log(1 + r) const ratio = savingsGoal / currentBalance; - const periodsToGoal = Math.log(ratio) / Math.log(1 + ratePerPeriod); - const months = Math.round(periodsToGoal * (12 / periodsPerYear)); - const interestEarned = savingsGoal - currentBalance; + const exactPeriodsToGoal = Math.log(ratio) / Math.log(1 + ratePerPeriod); + const periodsToGoal = Math.ceil(exactPeriodsToGoal - 1e-10); + const months = Math.ceil(periodsToGoal * (12 / periodsPerYear) - 1e-10); + const finalBalance = currentBalance * Math.pow(1 + ratePerPeriod, periodsToGoal); + const interestEarned = finalBalance - currentBalance; + periodsForBreakdown = periodsToGoal; results = { contributionPerPeriod: 0, totalDeposited: currentBalance, interestEarned: interestEarned, - finalBalance: savingsGoal, + finalBalance: finalBalance, timeInMonths: months, }; } } else if (contributionPerPeriod > 0) { // With contributions - const numerator = savingsGoal + contributionPerPeriod / ratePerPeriod; - const denominator = currentBalance + contributionPerPeriod / ratePerPeriod; - const periodsToGoal = Math.log(numerator / denominator) / Math.log(1 + ratePerPeriod); - const months = Math.round(periodsToGoal * (12 / periodsPerYear)); + let exactPeriodsToGoal: number; + if (ratePerPeriod === 0) { + // No interest: simple linear calculation + exactPeriodsToGoal = (savingsGoal - currentBalance) / contributionPerPeriod; + } else { + // With interest: use logarithmic formula + const numerator = savingsGoal + contributionPerPeriod / ratePerPeriod; + const denominator = currentBalance + contributionPerPeriod / ratePerPeriod; + exactPeriodsToGoal = Math.log(numerator / denominator) / Math.log(1 + ratePerPeriod); + } + + const periodsToGoal = Math.ceil(exactPeriodsToGoal - 1e-10); + const months = Math.ceil(periodsToGoal * (12 / periodsPerYear) - 1e-10); const futureValueOfInitial = currentBalance * Math.pow(1 + ratePerPeriod, periodsToGoal); - const futureValueOfAnnuity = - contributionPerPeriod * - ((Math.pow(1 + ratePerPeriod, periodsToGoal) - 1) / ratePerPeriod); - const finalBalance = futureValueOfInitial + futureValueOfAnnuity; - const totalDeposited = currentBalance + contributionPerPeriod * periodsToGoal; + const futureValueOfAnnuity = ratePerPeriod === 0 + ? contributionPerPeriod * periodsToGoal + : contributionPerPeriod * ((Math.pow(1 + ratePerPeriod, periodsToGoal) - 1) / ratePerPeriod); + const balanceWithFullFinalContribution = futureValueOfInitial + futureValueOfAnnuity; + // The last scheduled contribution may be smaller than the regular amount. + finalContributionReduction = Math.min( + contributionPerPeriod, + Math.max(0, balanceWithFullFinalContribution - savingsGoal) + ); + const finalBalance = balanceWithFullFinalContribution - finalContributionReduction; + const totalDeposited = + currentBalance + contributionPerPeriod * periodsToGoal - finalContributionReduction; + periodsForBreakdown = periodsToGoal; results = { contributionPerPeriod: contributionPerPeriod, @@ -313,11 +380,20 @@ export function calculateTimeToGoal( const breakdown = calculateYearlyBreakdown( results.contributionPerPeriod, - results.timeInMonths / (12 / periodsPerYear), + periodsForBreakdown, currentBalance, compounding, interestRate ); + if (finalContributionReduction > 0 && breakdown.length > 0) { + const finalRow = breakdown[breakdown.length - 1]; + breakdown[breakdown.length - 1] = { + ...finalRow, + contributions: finalRow.contributions - finalContributionReduction, + endingBalance: results.finalBalance, + }; + } + return { results, breakdown }; } diff --git a/app/interactives/savings-calculator/lib/validation.ts b/app/interactives/savings-calculator/lib/validation.ts index d5e901a..19aa834 100644 --- a/app/interactives/savings-calculator/lib/validation.ts +++ b/app/interactives/savings-calculator/lib/validation.ts @@ -13,12 +13,20 @@ export interface FieldInfo { [key: string]: string; } +export interface FieldWarnings { + [key: string]: string; +} + export interface ValidationResult { errors: FieldErrors; info: FieldInfo; + warnings: FieldWarnings; hasBlockingErrors: boolean; } +const GOAL_REACHED_WARNING = + "You've already reached this goal. Your current balance meets or exceeds your target."; + /** * Validate savings goal amount ($1 - $1,000,000,000) * Only shown in Tab 1 & 2, not in Tab 3 (future-balance) @@ -27,22 +35,28 @@ export function validateSavingsGoal( value: number, currentBalance: number, touched: boolean, - mode: CalculationMode -): { error?: string; info?: string } { + mode: CalculationMode, + isFocused = false +): { error?: string; info?: string; warning?: string } { if (mode === "future-balance") return {}; - if (touched && value === 0) { - return { error: "Please enter a savings goal amount." }; + // Do not interrupt someone who is clearing a previously completed field. + // The required-value error appears when they finish editing and leave it. + if (touched && value === 0 && isFocused) { + return {}; } - if (value !== 0 && (value < 1 || value > 1000000000)) { + if (touched && (value === 0 || value < 1 || value > 1000000000)) { return { error: "Enter an amount between $1 and $1,000,000,000" }; } - if (value > 0 && value <= currentBalance) { - return { - info: "You've already reached this goal. Your current balance meets or exceeds your target.", - }; + if ( + value > 0 && + value <= 1000000000 && + currentBalance <= 1000000000 && + value <= currentBalance + ) { + return { warning: GOAL_REACHED_WARNING }; } return {}; @@ -141,15 +155,21 @@ export function validateContributionPerPeriod( /** * Validate annual interest rate (0% - 30%) * Used in all tabs - * Special case: 0% is allowed but shows info message + * Special case: 0% is allowed and shows an advisory message */ export function validateInterestRate( value: number, - touched: boolean -): { error?: string; info?: string } { + touched: boolean, + isFocused?: boolean, + mode?: CalculationMode +): { error?: string; info?: string; warning?: string } { // Check for sentinel value (-1 = empty field) first, before range check if (value === -1) { if (touched) { + // For time-to-goal tab, defer error while editing. For other tabs, show immediately. + if (mode === "time-to-goal" && isFocused) { + return {}; + } return { error: "Please enter an annual interest rate." }; } // Not touched yet, so don't show error @@ -161,8 +181,14 @@ export function validateInterestRate( } if (value === 0) { + const message = "At 0%, your balance grows only from deposits. No interest is earned."; + + if (mode === "monthly-savings") { + return { warning: message }; + } + return { - info: "At 0%, your balance grows only from deposits. No interest is earned.", + info: message, }; } @@ -183,20 +209,24 @@ export function validateAllFields( contributionPerPeriod: number; }, touched: Record, - mode: CalculationMode + mode: CalculationMode, + focusedField?: string | null ): ValidationResult { const errors: FieldErrors = {}; const info: FieldInfo = {}; + const warnings: FieldWarnings = {}; // Validate savings goal const savingsGoalValidation = validateSavingsGoal( state.savingsGoal, state.currentBalance, touched.savingsGoal || false, - mode + mode, + focusedField === "savingsGoal" ); if (savingsGoalValidation.error) errors.savingsGoal = savingsGoalValidation.error; if (savingsGoalValidation.info) info.savingsGoal = savingsGoalValidation.info; + if (savingsGoalValidation.warning) warnings.savingsGoal = savingsGoalValidation.warning; // Validate current balance const currentBalanceValidation = validateCurrentBalance( @@ -205,6 +235,24 @@ export function validateAllFields( ); if (currentBalanceValidation.error) errors.currentBalance = currentBalanceValidation.error; + const hasReachedGoal = + mode !== "future-balance" && + state.savingsGoal > 0 && + state.savingsGoal <= 1000000000 && + state.currentBalance <= 1000000000 && + state.savingsGoal <= state.currentBalance; + + if (hasReachedGoal) { + warnings.currentBalance = GOAL_REACHED_WARNING; + + return { + errors, + info, + warnings, + hasBlockingErrors: true, + }; + } + // Validate time years const timeYearsValidation = validateTimeYears( state.timeYears, @@ -232,27 +280,68 @@ export function validateAllFields( if (contributionValidation.error) errors.contributionPerPeriod = contributionValidation.error; // Special case: Time to Goal tab - block if both currentBalance and contributionPerPeriod are 0 - // Only show error after user has touched one of these fields + // Only show error after user has touched AND finished editing one of these fields if ( mode === "time-to-goal" && state.currentBalance === 0 && state.contributionPerPeriod === 0 && - (touched.currentBalance || touched.contributionPerPeriod) + (touched.currentBalance || touched.contributionPerPeriod) && + focusedField !== "currentBalance" && + focusedField !== "contributionPerPeriod" ) { errors.contributionPerPeriod = "Enter a savings amount or contribution to calculate time to goal."; } + // Special case: Time to Goal tab - warning if contribution per period meets or exceeds savings goal + if ( + mode === "time-to-goal" && + state.savingsGoal > 0 && + state.contributionPerPeriod > 0 && + state.contributionPerPeriod >= state.savingsGoal + ) { + warnings.contributionPerPeriod = "You've already reached this goal. The amount entered in saving per compounding period meets or exceeds your target."; + } + + // Special case: Time to Goal tab - at 0% interest with no contributions, block calculation + if ( + mode === "time-to-goal" && + state.interestRate === 0 && + state.savingsGoal > state.currentBalance && + state.currentBalance > 0 && + state.contributionPerPeriod === 0 + ) { + warnings.interestRate = "At 0%, your balance grows only from deposits. Since no contributions are entered you will not reach your goal."; + // Clear the info message since we're showing a blocking warning instead + delete info.interestRate; + } + // Validate interest rate const interestRateValidation = validateInterestRate( state.interestRate, - touched.interestRate || false + touched.interestRate || false, + focusedField === "interestRate", + mode ); if (interestRateValidation.error) errors.interestRate = interestRateValidation.error; - if (interestRateValidation.info) info.interestRate = interestRateValidation.info; + if (interestRateValidation.warning) warnings.interestRate = interestRateValidation.warning; + // Only show the info message if we don't have a blocking warning for this field + if (interestRateValidation.info && !warnings.interestRate) info.interestRate = interestRateValidation.info; + + // For time-to-goal mode, the remaining warnings are also blocking + const hasWarningsThatBlock = mode === "time-to-goal" && Object.keys(warnings).length > 0; + const hasFocusedEmptySavingsGoal = + mode !== "future-balance" && + state.savingsGoal === 0 && + (touched.savingsGoal || false) && + focusedField === "savingsGoal"; return { errors, info, - hasBlockingErrors: Object.keys(errors).length > 0, + warnings, + hasBlockingErrors: + Object.keys(errors).length > 0 || + hasWarningsThatBlock || + hasFocusedEmptySavingsGoal, }; } diff --git a/app/interactives/savings-calculator/page.tsx b/app/interactives/savings-calculator/page.tsx index f48932e..a0df173 100644 --- a/app/interactives/savings-calculator/page.tsx +++ b/app/interactives/savings-calculator/page.tsx @@ -35,6 +35,17 @@ interface YearlyBreakdown { endingBalance: number } +function formatDuration(totalMonths: number) { + const years = Math.floor(totalMonths / 12) + const months = totalMonths % 12 + const parts: string[] = [] + + if (years > 0) parts.push(`${years} ${years === 1 ? "year" : "years"}`) + if (months > 0) parts.push(`${months} ${months === 1 ? "month" : "months"}`) + + return parts.join(" and ") +} + export default function SavingsCalculator() { const [mode, setMode] = useState("monthly-savings") const [savingsGoal, setSavingsGoal] = useState(0) @@ -67,6 +78,18 @@ export default function SavingsCalculator() { contributionPerPeriod: false, }) + // Track which fields have been edited (onChange fired) + const [editedFields, setEditedFields] = useState>({ + savingsGoal: false, + currentBalance: false, + timeYears: false, + timeMonths: false, + contributionPerPeriod: false, + }) + + // Track which field currently has focus (for deferring error messages) + const [focusedField, setFocusedField] = useState(null) + // Clear all input fields when switching tabs useEffect(() => { setSavingsGoal(0); @@ -83,6 +106,14 @@ export default function SavingsCalculator() { interestRate: false, contributionPerPeriod: false, }); + setEditedFields({ + savingsGoal: false, + currentBalance: false, + timeYears: false, + timeMonths: false, + contributionPerPeriod: false, + }); + setFocusedField(null); }, [mode]); // Calculate results based on mode using library functions @@ -152,9 +183,17 @@ export default function SavingsCalculator() { contributionPerPeriod, }, touched, - mode + mode, + focusedField ); + const hasReachedGoal = + mode !== "future-balance" && + savingsGoal > 0 && + savingsGoal <= 1000000000 && + currentBalance <= 1000000000 && + savingsGoal <= currentBalance; + // Handle field blur - mark field as touched const handleFieldBlur = (fieldName: string) => { setTouched((prev) => ({ @@ -179,6 +218,14 @@ export default function SavingsCalculator() { interestRate: false, contributionPerPeriod: false, }); + setEditedFields({ + savingsGoal: false, + currentBalance: false, + timeYears: false, + timeMonths: false, + contributionPerPeriod: false, + }); + setFocusedField(null); setResults({ contributionPerPeriod: NaN, totalDeposited: NaN, @@ -237,7 +284,7 @@ export default function SavingsCalculator() {