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() {
)}
- {validation.info.savingsGoal && !validation.errors.savingsGoal && (
+ {validation.warnings.savingsGoal && (
+
+ {validation.warnings.savingsGoal}
+
+ )}
+ {validation.info.savingsGoal && !validation.errors.savingsGoal && !validation.warnings.savingsGoal && (
{validation.info.savingsGoal}
@@ -319,13 +382,20 @@ export default function SavingsCalculator() {
setCurrentBalance(Number(e.target.value))}
+ onChange={(e) => {
+ setCurrentBalance(Number(e.target.value) || 0);
+ setEditedFields(prev => ({ ...prev, currentBalance: e.target.value !== "" }));
+ }}
onBlur={() => handleFieldBlur("currentBalance")}
className={`block w-full rounded-md shadow-sm py-2 border pl-8 pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${
- validation.errors.currentBalance ? "border-[var(--color-inline-error)]" : "border-input"
+ validation.errors.currentBalance
+ ? "border-[var(--color-inline-error)]"
+ : validation.warnings.currentBalance
+ ? "border-[var(--color-inline-warning)]"
+ : "border-input"
}`}
/>
@@ -337,6 +407,11 @@ export default function SavingsCalculator() {
{validation.errors.currentBalance}
)}
+ {validation.warnings.currentBalance && !validation.errors.currentBalance && (
+
+ {validation.warnings.currentBalance}
+
+ )}
{mode !== "monthly-savings" && (
@@ -346,21 +421,36 @@ export default function SavingsCalculator() {
setcontributionPerPeriod(Number(e.target.value))}
- onBlur={() => handleFieldBlur("contributionPerPeriod")}
+ value={hasReachedGoal ? "" : contributionPerPeriod === 0 && !editedFields.contributionPerPeriod ? "" : contributionPerPeriod}
+ placeholder={hasReachedGoal ? "-" : ""}
+ disabled={hasReachedGoal}
+ onChange={(e) => {
+ setcontributionPerPeriod(Number(e.target.value) || 0);
+ setEditedFields(prev => ({ ...prev, contributionPerPeriod: e.target.value !== "" }));
+ }}
+ onFocus={() => setFocusedField("contributionPerPeriod")}
+ onBlur={() => {
+ handleFieldBlur("contributionPerPeriod");
+ setFocusedField(null);
+ }}
className={`block w-full rounded-md shadow-sm py-2 border pl-8 pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${
validation.errors.contributionPerPeriod ? "border-[var(--color-inline-error)]" : "border-input"
}`}
/>
- $
+ {!hasReachedGoal && (
+ $
+ )}
{validation.errors.contributionPerPeriod && (
{validation.errors.contributionPerPeriod}
)}
+ {validation.warnings.contributionPerPeriod && (
+
+ {validation.warnings.contributionPerPeriod}
+
+ )}
)}
@@ -376,9 +466,13 @@ export default function SavingsCalculator() {
setTimeYears(Number(e.target.value))}
+ value={hasReachedGoal ? "" : timeYears === 0 && !editedFields.timeYears ? "" : timeYears}
+ placeholder={hasReachedGoal ? "-" : ""}
+ disabled={hasReachedGoal}
+ onChange={(e) => {
+ setTimeYears(Number(e.target.value) || 0);
+ setEditedFields(prev => ({ ...prev, timeYears: e.target.value !== "" }));
+ }}
onBlur={() => handleFieldBlur("timeYears")}
className={`block w-full rounded-md shadow-sm py-2 px-3 border pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${
validation.errors.timeYears ? "border-[var(--color-inline-error)]" : "border-input"
@@ -399,9 +493,13 @@ export default function SavingsCalculator() {
setTimeMonths(parseInt(e.target.value) || 0)}
+ value={hasReachedGoal ? "" : timeMonths === 0 && !editedFields.timeMonths ? "" : timeMonths}
+ placeholder={hasReachedGoal ? "-" : ""}
+ disabled={hasReachedGoal}
+ onChange={(e) => {
+ setTimeMonths(parseInt(e.target.value) || 0);
+ setEditedFields(prev => ({ ...prev, timeMonths: e.target.value !== "" }));
+ }}
onBlur={() => handleFieldBlur("timeMonths")}
className={`block w-full rounded-md shadow-sm py-2 px-3 border pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${
validation.errors.timeMonths ? "border-[var(--color-inline-error)]" : "border-input"
@@ -430,24 +528,42 @@ export default function SavingsCalculator() {
type="number"
step="0.1"
min="0.1"
- value={interestRate === -1 ? "" : interestRate === 0 ? "0" : interestRate}
- placeholder=""
+ value={hasReachedGoal ? "" : interestRate === -1 ? "" : interestRate === 0 ? "0" : interestRate}
+ placeholder={hasReachedGoal ? "-" : ""}
+ disabled={hasReachedGoal}
onChange={(e) => {
const val = e.target.value;
// Set to -1 if empty string, otherwise parse as number
setInterestRate(val === "" ? -1 : Number(val));
}}
- onBlur={() => handleFieldBlur("interestRate")}
- className={`block w-full rounded-md shadow-sm py-2 px-3 border pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`}
+ onFocus={() => setFocusedField("interestRate")}
+ onBlur={() => {
+ handleFieldBlur("interestRate");
+ setFocusedField(null);
+ }}
+ className={`block w-full rounded-md shadow-sm py-2 px-3 border pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${
+ validation.errors.interestRate
+ ? "border-[var(--color-inline-error)]"
+ : validation.warnings.interestRate
+ ? "border-[var(--color-inline-warning)]"
+ : "border-input"
+ }`}
/>
- %
+ {!hasReachedGoal && (
+ %
+ )}
{validation.errors.interestRate && (
{validation.errors.interestRate}
)}
- {validation.info.interestRate && !validation.errors.interestRate && (
+ {validation.warnings.interestRate && (
+
+ {validation.warnings.interestRate}
+
+ )}
+ {validation.info.interestRate && !validation.errors.interestRate && !validation.warnings.interestRate && (
{validation.info.interestRate}
@@ -458,10 +574,12 @@ export default function SavingsCalculator() {
+ {results.contributionPerPeriod === 0 &&
+ savingsGoal > currentBalance &&
+ results.timeInMonths > 0 &&
+ !overflowWarning && (
+
+ No further contributions are needed — due to compounding, you'll reach your goal in {formatDuration(results.timeInMonths)}.
+
+ )}
>
)}
@@ -565,7 +691,7 @@ export default function SavingsCalculator() {
: `$${results.interestEarned.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
- {mode !== "future-balance" && (
+ {mode === "future-balance" && (
Final balance:
@@ -614,41 +740,46 @@ export default function SavingsCalculator() {
- {yearlyBreakdown.map((year) => (
-
- |
- {year.year}
- |
-
- {isInvalid(year.startingBalance)
- ? "-"
- : isOverflow(year.startingBalance)
- ? "Too large to display"
- : `$${year.startingBalance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
- |
-
- {isInvalid(year.contributions)
- ? "-"
- : isOverflow(year.contributions)
- ? "Too large to display"
- : `$${year.contributions.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
- |
-
- {isInvalid(year.interestEarned)
- ? "-"
- : isOverflow(year.interestEarned)
- ? "Too large to display"
- : `$${year.interestEarned.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
- |
-
- {isInvalid(year.endingBalance)
- ? "-"
- : isOverflow(year.endingBalance)
- ? "Too large to display"
- : `$${year.endingBalance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
- |
-
- ))}
+ {yearlyBreakdown.map((year) => {
+ const displayYear = Number.isInteger(year.year)
+ ? year.year
+ : year.year.toFixed(1);
+ return (
+
+ |
+ {displayYear}
+ |
+
+ {isInvalid(year.startingBalance)
+ ? "-"
+ : isOverflow(year.startingBalance)
+ ? "Too large to display"
+ : `$${year.startingBalance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
+ |
+
+ {isInvalid(year.contributions)
+ ? "-"
+ : isOverflow(year.contributions)
+ ? "Too large to display"
+ : `$${year.contributions.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
+ |
+
+ {isInvalid(year.interestEarned)
+ ? "-"
+ : isOverflow(year.interestEarned)
+ ? "Too large to display"
+ : `$${year.interestEarned.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
+ |
+
+ {isInvalid(year.endingBalance)
+ ? "-"
+ : isOverflow(year.endingBalance)
+ ? "Too large to display"
+ : `$${year.endingBalance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
+ |
+
+ );
+ })}
@@ -661,4 +792,3 @@ export default function SavingsCalculator() {
)
}
-
\ No newline at end of file