diff --git a/README.md b/README.md index 94b3191..8d479c4 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,32 @@ This repository serves as source/hosting for an interactive learning tool. The g --- -## Deployment +## Environments -Please see documentation on deployment and embedding here: [deployment.md](docs/deployment.md) +| Environment | URL | Branch | Deploy command | +|---|---|---|---| +| Production | https://ifdm-learning.stanford.edu/ | `1.x` | `yarn deploy` | +| Staging | https://su-sws.github.io/ifdm_learning_apps_staging/ | `dev` | `yarn deploy:staging` | + +### Development workflow + +New features are developed on feature branches, reviewed via pull request, then deployed to staging for QA before going to production: + +``` +feature/my-branch → PR into dev → yarn deploy:staging → review on staging + ↓ approved + PR into 1.x → yarn deploy → production +``` + +1. Create a feature branch off `dev` (or `1.x` for urgent fixes) +2. Open a PR targeting `dev` +3. After merge, deploy to staging and verify: `git checkout dev && git pull && yarn deploy:staging` +4. Once staging looks good, open a PR from `dev` into `1.x` +5. After merge, deploy to production: `git checkout 1.x && git pull && yarn deploy` + +The deploy scripts enforce branch — `yarn deploy` will fail if you're not on `1.x`, and `yarn deploy:staging` will fail if you're not on `dev`. + +For full deployment details and troubleshooting, see [docs/deployment.md](docs/deployment.md). ## Local Setup diff --git a/app/interactives/compounding-frequency-calculator/page.tsx b/app/interactives/compounding-frequency-calculator/page.tsx index 84d34dc..3a10c68 100644 --- a/app/interactives/compounding-frequency-calculator/page.tsx +++ b/app/interactives/compounding-frequency-calculator/page.tsx @@ -47,6 +47,34 @@ function calculateCompoundInterest( const MAX_INITIAL_AMOUNT = 100_000_000 // 100 million const MAX_ANNUAL_RATE = 1000 // 1,000% +const periodPluralLabels: Record = { + annually: "years", + "semi-annually": "semi-annual periods", + quarterly: "quarters", + monthly: "months", + biweekly: "bi-weekly periods", + weekly: "weeks", + daily: "days", +} + +const freqLabels: Record = { + annually: "annual", + "semi-annually": "semiannual", + quarterly: "quarterly", + monthly: "monthly", + biweekly: "biweekly", + weekly: "weekly", + daily: "daily", +} + +function buildPeriodsRangeError(compounding: CompoundingPeriod, max: number): string { + const label = periodPluralLabels[compounding] + const maxFormatted = max.toLocaleString("en-US") + const base = `Enter a number of ${label} between 0 and ${maxFormatted}.` + if (compounding === "annually") return base + return `${base} (${maxFormatted} periods = 100 years with ${freqLabels[compounding]} compounding).` +} + export default function CompoundInterestCalculator() { const [initialAmount, setInitialAmount] = useState("") const [annualRate, setAnnualRate] = useState("") @@ -81,10 +109,8 @@ export default function CompoundInterestCalculator() { const [initialAmountError, setInitialAmountError] = useState("") const [annualRateError, setAnnualRateError] = useState("") - const hasError = - !!initialAmountError || - !!annualRateError || - Number(periods) > maxPeriods + const [periodsError, setPeriodsError] = useState("") + const hasError = !!initialAmountError || !!annualRateError || !!periodsError type CompoundingPeriod = 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'semi-annually' | 'annually'; @@ -136,9 +162,9 @@ export default function CompoundInterestCalculator() { numericValue > MAX_INITIAL_AMOUNT ) { setInitialAmountError( - "Initial amount cannot exceed $100,000,000.", + "Enter an amount between $0 and $100,000,000.", ); - setInitialAmount(numericPart); // ← update state even on error + setInitialAmount(numericPart); } else { setInitialAmountError(""); setInitialAmount(numericPart); @@ -147,6 +173,8 @@ export default function CompoundInterestCalculator() { onBlur={() => { if (initialAmount.startsWith(".")) setInitialAmount("0" + initialAmount); + if (!initialAmount) + setTimeout(() => setInitialAmountError("Enter an initial amount."), 150); }} min="0" className={`block w-full pl-8 rounded-md shadow-sm border [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${initialAmountError ? "border-[var(--color-inline-error)] border-2" : ""}`} @@ -177,7 +205,6 @@ export default function CompoundInterestCalculator() { id="annual-rate" type="text" value={annualRate} - // Annual rate onChange onChange={(e) => { const input = e.target.value; const numericPart = input.replace(/[^0-9.]/g, ""); @@ -187,9 +214,9 @@ export default function CompoundInterestCalculator() { numericValue > MAX_ANNUAL_RATE ) { setAnnualRateError( - "Annual interest rate cannot exceed 1,000%.", + "Enter a rate between 0% and 1,000%.", ); - setAnnualRate(numericPart); // ← update state even on error + setAnnualRate(numericPart); } else { setAnnualRateError(""); setAnnualRate(numericPart); @@ -198,6 +225,8 @@ export default function CompoundInterestCalculator() { onBlur={() => { if (annualRate.startsWith(".")) setAnnualRate("0" + annualRate); + if (!annualRate) + setTimeout(() => setAnnualRateError("Please enter an interest rate."), 150); }} 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 ${annualRateError ? "border-[var(--color-inline-error)] border-2" : ""}`} min="0" @@ -241,29 +270,33 @@ export default function CompoundInterestCalculator() { value={periods} onChange={(e) => { const val = e.target.value; - if (val === "" || Number(val) >= 0) setPeriods(val); + if (val === "" || Number(val) >= 0) { + setPeriods(val); + if (val !== "" && Number(val) > maxPeriods) { + setPeriodsError(buildPeriodsRangeError(selectedCompounding, maxPeriods)); + } else { + setPeriodsError(""); + } + } }} onKeyDown={(e) => { if (e.key === "-" || e.key === "e") e.preventDefault(); }} onBlur={() => { - if (periods.startsWith(".")) { - setPeriods("0" + periods); - } + if (periods.startsWith(".")) setPeriods("0" + periods); + if (!periods) + setTimeout(() => setPeriodsError("Enter a number of compounding periods."), 150); }} - 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" + 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 ${periodsError ? "border-[var(--color-inline-error)] border-2" : ""}`} min="0" /> - {Number(periods) > maxPeriods && ( + {periodsError && (

- Number of periods cannot exceed{" "} - {maxPeriods.toLocaleString("en-US")} ( - {selectedOption.label.toLowerCase()} compounding caps at 100 - years). + {periodsError}

)} @@ -286,9 +319,19 @@ export default function CompoundInterestCalculator() { setDebtAmount(Number(e.target.value.replace(/,/g, "")) || 0)} min="1" - value={debtAmount === 0 ? "" : debtAmount} - onChange={(e) => setDebtAmount(Number(e.target.value) || 0)} className="font-bold 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" /> -
- - -
- + This is the annual percentage rate (APR) charged by your lender.
@@ -220,34 +200,17 @@ export default function DebtPayoffCalculator() { value={interestRate === 0 ? "" : interestRate} onChange={(e) => setInterestRate(Number(e.target.value) || 0)} min="0.1" - className="relative font-bold block w-full text-lagunita 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" + className="relative font-bold block w-full text-[var(--color-teal)] 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" /> -
- - -
+ + % +
- + How often interest is applied and payments are made. Most loans compound monthly.
@@ -281,78 +244,41 @@ export default function DebtPayoffCalculator() {
- This is your frequency of payment and how often the interest is applied. For example, each month or each year. + This is the amount you pay each period at the selected frequency (e.g. $100 per month)
setPayment(Number(e.target.value.replace(/,/g, "")) || 0)} step="0.01" - value={payment === 0 ? "" : payment} - onChange={(e) => setPayment(Number(e.target.value) || 0)} className="font-bold 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" /> -
- - -
+
- + Enter a fixed extra amount you plan to pay each month.
setAdditionalPayment(e.target.value === "" ? "" : Number(e.target.value))} - onBlur={(e) => setAdditionalPayment(e.target.value === "" ? 0 : Number(e.target.value))} - className="font-bold text-lagunita 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" + type="text" + inputMode="numeric" + value={additionalPayment === "" ? "" : Number(additionalPayment).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + onChange={(e) => setAdditionalPayment(e.target.value === "" ? "" : Number(e.target.value.replace(/,/g, "")))} + onBlur={(e) => setAdditionalPayment(e.target.value === "" ? 0 : Number(e.target.value.replace(/,/g, "")))} + className="font-bold text-[var(--color-teal)] 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" /> -
- - -

Each steady extra payment reduces your total interest. @@ -361,15 +287,15 @@ export default function DebtPayoffCalculator() { - + Your payoff summary

Time to pay off

-

{formatTime(payoffResult.timeInMonths)}

-

Debt-free by {formatDate(payoffResult.payoffDate)}

+

{formatTime(payoffResult.timeInMonths)}

+

Debt-free by {formatDate(payoffResult.payoffDate)}

@@ -395,7 +321,7 @@ export default function DebtPayoffCalculator() {
Interest saved:
-
+
{formatCurrency(payoffResult.interestSaved)}
@@ -424,38 +350,19 @@ export default function DebtPayoffCalculator() {
setDebtAmount(Number(e.target.value) || 0)} + type="text" + inputMode="numeric" + value={debtAmount === 0 ? "" : debtAmount.toLocaleString("en-US")} + onChange={(e) => setDebtAmount(Number(e.target.value.replace(/,/g, "")) || 0)} min="1" className="font-bold 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" /> -
- - -
- + This is the annual percentage rate (APR) charged by your lender.
@@ -468,32 +375,15 @@ export default function DebtPayoffCalculator() { min="0.1" className="font-bold text-lagunita 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" /> -
- - -
+ + % +
- + How often interest is applied and payments are made. Most loans compound monthly.
@@ -530,7 +420,10 @@ export default function DebtPayoffCalculator() { How long do you want to take to pay off this debt?
-
+
+
setTargetYears(Number(e.target.value) || 0)} className="font-bold 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" /> -
- - -
-
@@ -576,50 +446,30 @@ export default function DebtPayoffCalculator() { onChange={(e) => setTargetMonths(Math.min(11, Math.max(0, Number(e.target.value)) || 0))} className="font-bold 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" /> -
- - -
-
+
Total: {targetYears} year{targetYears !== 1 ? "s" : ""} {targetMonths} month{targetMonths !== 1 ? "s" : ""}
- + Required payment

Payment per period

-

+

{formatCurrency(requiredPaymentResult.requiredPayment)}

-

To pay off in {formatTime(targetYears * 12 + targetMonths)}

+

To pay off in {formatTime(targetYears * 12 + targetMonths)}

diff --git a/app/interactives/mortgage-calculator-v2/page.tsx b/app/interactives/mortgage-calculator-v2/page.tsx index df24bce..c9a2e66 100644 --- a/app/interactives/mortgage-calculator-v2/page.tsx +++ b/app/interactives/mortgage-calculator-v2/page.tsx @@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/app/ui/components/ca import InfoPopover from "@/app/ui/components/popover"; export default function MortgageCalculator() { - const [mode, setMode] = useState('afford'); // 'afford', 'affordability', 'payment' + const [mode, setMode] = useState('afford'); const [monthlyPayment, setMonthlyPayment] = useState(""); const [homePrice, setHomePrice] = useState(""); const [downPaymentPercent, setDownPaymentPercent] = useState(20); @@ -24,9 +24,8 @@ export default function MortgageCalculator() { const [propertyTaxAmount, setPropertyTaxAmount] = useState(0); const [homeInsuranceMode, setHomeInsuranceMode] = useState('percentage'); const [homeInsuranceAmount, setHomeInsuranceAmount] = useState(0); - - // NEW: Store unrounded calculated values for accurate conversions const [calculatedHomePrice, setCalculatedHomePrice] = useState(0); + const [showRateError, setShowRateError] = useState(false); const [results, setResults] = useState({ homePrice: 0, @@ -41,8 +40,8 @@ export default function MortgageCalculator() { }); const calculateMortgage = useCallback(() => { - const r = (Number(interestRate) / 100 / 12); // Monthly interest rate - const n = loanTerm * 12; // Total number of payments + const r = (Number(interestRate) / 100 / 12); + const n = loanTerm * 12; const hoaDuesNum = Number(hoaDues) || 0; if (mode === 'afford') { @@ -50,51 +49,27 @@ export default function MortgageCalculator() { const interestRateValue = Number(interestRate); if (paymentAmount <= 0 || interestRateValue <= 0) { - setResults({ - homePrice: 0, - downPayment: 0, - loanAmount: 0, - monthlyMortgage: 0, - monthlyTax: 0, - monthlyInsurance: 0, - totalMonthly: 0, - hoaDues: 0, - totalMonthlyHousingCost: 0 - }); + setResults({ homePrice: 0, downPayment: 0, loanAmount: 0, monthlyMortgage: 0, monthlyTax: 0, monthlyInsurance: 0, totalMonthly: 0, hoaDues: 0, totalMonthlyHousingCost: 0 }); setCalculatedHomePrice(0); return; } - // Calculate loan amount from desired monthly payment const loanAmount = paymentAmount * ((Math.pow(1 + r, n) - 1) / (r * Math.pow(1 + r, n))); let computedHomePrice: number; let downPayment: number; if (downPaymentMode === 'dollar') { - // In dollar mode: homePrice = loanAmount + fixedDownPaymentAmount downPayment = downPaymentAmount; computedHomePrice = loanAmount + downPayment; - // Update the percent to reflect the actual down payment percentage if (computedHomePrice > 0) { const calculatedPercent = (downPayment / computedHomePrice) * 100; setDownPaymentPercent(calculatedPercent); setDownPaymentPercentInput(calculatedPercent.toFixed(2)); } } else { - // In percentage mode: homePrice = loanAmount / (1 - downPaymentPercent/100) const safeDownPaymentPercent = clampPercent(downPaymentPercent); if (safeDownPaymentPercent >= 100) { - setResults({ - homePrice: 0, - downPayment: 0, - loanAmount: 0, - monthlyMortgage: 0, - monthlyTax: 0, - monthlyInsurance: 0, - totalMonthly: 0, - hoaDues: 0, - totalMonthlyHousingCost: 0 - }); + setResults({ homePrice: 0, downPayment: 0, loanAmount: 0, monthlyMortgage: 0, monthlyTax: 0, monthlyInsurance: 0, totalMonthly: 0, hoaDues: 0, totalMonthlyHousingCost: 0 }); setCalculatedHomePrice(0); return; } @@ -102,15 +77,12 @@ export default function MortgageCalculator() { downPayment = computedHomePrice * (safeDownPaymentPercent / 100); } - // Store unrounded value for accurate conversions setCalculatedHomePrice(computedHomePrice); - // Handle property tax correctly based on mode const monthlyTax = propertyTaxMode === 'percentage' ? (computedHomePrice * (propertyTaxPercent / 100)) / 12 : propertyTaxAmount / 12; - // Handle home insurance correctly based on mode const monthlyInsurance = homeInsuranceMode === 'percentage' ? (computedHomePrice * (homeInsurancePercent / 100)) / 12 : homeInsuranceAmount / 12; @@ -134,43 +106,21 @@ export default function MortgageCalculator() { const downPayment = homePriceAmount * (safeDownPaymentPercent / 100); const loanAmount = clampNonNegativeNumber(homePriceAmount - downPayment); - if (homePriceAmount <= 0 || Number(interestRate) <= 0 || safeDownPaymentPercent >= 100) { - setResults({ - homePrice: 0, - downPayment: 0, - loanAmount: 0, - monthlyMortgage: 0, - monthlyTax: 0, - monthlyInsurance: 0, - totalMonthly: 0, - hoaDues: 0, - totalMonthlyHousingCost: 0 - }); + if (homePriceAmount <= 0 || safeDownPaymentPercent >= 100) { + setResults({ homePrice: 0, downPayment: 0, loanAmount: 0, monthlyMortgage: 0, monthlyTax: 0, monthlyInsurance: 0, totalMonthly: 0, hoaDues: 0, totalMonthlyHousingCost: 0 }); return; } const monthlyMortgage = loanAmount * (r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1); if (!isFinite(monthlyMortgage) || monthlyMortgage < loanAmount * r) { - setResults({ - homePrice: 0, - downPayment: 0, - loanAmount: 0, - monthlyMortgage: 0, - monthlyTax: 0, - monthlyInsurance: 0, - totalMonthly: 0, - hoaDues: 0, - totalMonthlyHousingCost: 0 - }); + setResults({ homePrice: 0, downPayment: 0, loanAmount: 0, monthlyMortgage: 0, monthlyTax: 0, monthlyInsurance: 0, totalMonthly: 0, hoaDues: 0, totalMonthlyHousingCost: 0 }); return; } - // Handle property tax correctly based on mode const monthlyTax = propertyTaxMode === 'percentage' ? (Number(homePrice) * (propertyTaxPercent / 100)) / 12 : propertyTaxAmount / 12; - // Handle home insurance correctly based on mode const monthlyInsurance = homeInsuranceMode === 'percentage' ? (Number(homePrice) * (homeInsurancePercent / 100)) / 12 : homeInsuranceAmount / 12; @@ -226,7 +176,7 @@ export default function MortgageCalculator() { (Number(homePrice) > 0 && downPaymentAmount > Number(homePrice)) ); const showAffordResults = mode === 'afford' && Number(monthlyPayment) > 0 && Number(interestRate) > 0 && downPaymentPercent < 100; - const showPaymentResults = mode === 'payment' && Number(homePrice) > 0 && Number(interestRate) > 0 && downPaymentPercent < 100; + const showPaymentResults = mode === 'payment' && Number(homePrice) > 0 && interestRate !== "" && downPaymentPercent < 100; const handleReset = () => { setMonthlyPayment(''); @@ -246,17 +196,8 @@ export default function MortgageCalculator() { setHoaDues(''); setDownPaymentMode('percentage'); setCalculatedHomePrice(0); - setResults({ - homePrice: 0, - downPayment: 0, - loanAmount: 0, - monthlyMortgage: 0, - monthlyTax: 0, - monthlyInsurance: 0, - totalMonthly: 0, - hoaDues: 0, - totalMonthlyHousingCost: 0 - }); + setShowRateError(false); + setResults({ homePrice: 0, downPayment: 0, loanAmount: 0, monthlyMortgage: 0, monthlyTax: 0, monthlyInsurance: 0, totalMonthly: 0, hoaDues: 0, totalMonthlyHousingCost: 0 }); } return ( @@ -264,111 +205,160 @@ export default function MortgageCalculator() {
- {/* Header */}

Mortgage Calculator Suite

+ + {/* Fix 6: role="alert" so screen readers announce the error */} {hasValidationError && ( -
+
{validationErrorMessage}
)} - setMode(v)} className="w-full"> + { setMode(v); setShowRateError(false); }} + className="w-full" + > - Home you can afford - Monthly payment + + Home you can afford + + + Monthly payment + + {/* ── Tab 1: Afford ─────────────────────────────────────────── */} -

Estimate home price based on a monthly payment you can allocate toward a mortgage.

+

+ Estimate home price based on a monthly payment you can allocate + toward a mortgage. +

+ {/* Monthly payment */}
-
-
- - Enter the amount you can afford for your loan payment (principal + interest only). Taxes, insurance, and HOA fees are added separately below. -
+
+ {/* Fix 1: htmlFor links label to input */} + + + Enter the amount you can afford for your loan payment + (principal + interest only). Taxes, insurance, and HOA + fees are added separately below. +
- $ + {/* Fix 2: aria-hidden on decorative symbols */} + { - const raw = e.target.value; - if (raw === '') { - setMonthlyPayment(''); - return; + const raw = e.target.value.replace(/,/g, ""); + if (raw === "" || /^\d*\.?\d*$/.test(raw)) { + setMonthlyPayment(raw); } - setMonthlyPayment(Math.max(0, Number(raw)).toString()); }} className="w-full pl-8 pr-4 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" />
-

This is the amount allocated to the loan payment (principal + interest). Taxes, insurance, and HOA are added separately below.

+

+ This is the amount allocated to the loan payment + (principal + interest). Taxes, insurance, and HOA are + added separately below. +

+ {/* Down Payment */}
- -
- - -
+ + Down payment + + {/* Fix 4: fieldset+legend for radio group */} +
+ Down payment type +
+ + +
+
- {downPaymentMode === 'percentage' ? ( + {downPaymentMode === "percentage" ? (
{ const raw = e.target.value; setDownPaymentPercentInput(raw); - if (raw === '') { + if (raw === "") { setDownPaymentPercent(0); setDownPaymentAmount(0); - setDownPaymentAmountInput('0'); + setDownPaymentAmountInput("0"); return; } const value = clampPercent(Number(raw)); setDownPaymentPercent(value); - // FIX: Use unrounded calculatedHomePrice for conversion const price = calculatedHomePrice || 0; const computedAmount = (value / 100) * price; setDownPaymentAmount(computedAmount); - setDownPaymentAmountInput(Math.round(computedAmount).toString()); + setDownPaymentAmountInput( + Math.round(computedAmount).toString(), + ); }} + aria-label="Down payment percentage" 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" /> - +
) : (
{ - const raw = e.target.value; - setDownPaymentAmountInput(raw); - if (raw === '') { - setDownPaymentAmount(0); - setDownPaymentPercent(0); - setDownPaymentPercentInput('0'); - return; + const raw = e.target.value.replace(/,/g, ""); + if (raw === "" || /^\d*$/.test(raw)) { + setDownPaymentAmountInput(raw); + if (raw === "") { + setDownPaymentAmount(0); + setDownPaymentPercent(0); + setDownPaymentPercentInput("0"); + return; + } + setDownPaymentAmount(Number(raw)); } - const amountValue = Number(raw); - setDownPaymentAmount(amountValue); - // Percentage will be recalculated in calculateMortgage }} - 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" + aria-label="Down payment amount in dollars" + 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" /> - $ +
)} -

Enter 0 if no down payment is planned.

+

+ Enter 0 if no down payment is planned. +

{/* Interest Rate */}
- +
{ const raw = e.target.value; - if (raw === '') { - setInterestRate(''); + if (raw === "") { + setInterestRate(""); return; } setInterestRate(Math.max(0, Number(raw)).toString()); }} - className="w-full pr-8 pl-4 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" + onBlur={() => { + if (interestRate === "" && monthlyPayment !== "") { + setShowRateError(true); + } else { + setShowRateError(false); + } + }} + aria-describedby={ + showRateError + ? "interest-rate-afford-error" + : undefined + } + aria-invalid={showRateError} + className={`w-full pr-8 pl-4 py-3 border-2 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 ${showRateError ? "border-[var(--color-inline-error)] border-2" : ""}`} /> - % +
+ {showRateError && ( + + )}
- {/* Loan Term */} + {/* Loan Term — Fix 4: fieldset+legend */}
- -
- - -
+
+ + Loan term + +
+ + +
+
+ {/* Optional Section */}
-

Additional housing costs

+ {/* Fix 7: proper h2 heading */} +

+ Additional housing costs +

- {/* Property Taxes */} + {/* Property Taxes */}
- -
- - -
+ + Property taxes (annual) + +
+ + Property tax input type + +
+ + +
+
- {/* Property Tax Input */} - {propertyTaxMode === 'percentage' ? ( + {propertyTaxMode === "percentage" ? (
{ - const raw = e.target.value; - const value = clampNonNegativeNumber(raw === '' ? 0 : Number(raw)); + const value = clampNonNegativeNumber( + e.target.value === "" + ? 0 + : Number(e.target.value), + ); setPropertyTaxPercent(value); - // FIX: Use unrounded calculatedHomePrice - const price = calculatedHomePrice || 0; - setPropertyTaxAmount((value / 100) * price); + setPropertyTaxAmount( + (value / 100) * (calculatedHomePrice || 0), + ); }} + aria-label="Property tax percentage" 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" /> - +
) : (
{ - const raw = e.target.value; - const value = clampNonNegativeNumber(raw === '' ? 0 : Number(raw)); - setPropertyTaxAmount(value); - // FIX: Use unrounded calculatedHomePrice - const price = calculatedHomePrice || 0; - if (price > 0) setPropertyTaxPercent((value / price) * 100); + const raw = e.target.value.replace(/,/g, ""); + if (raw === "" || /^\d*$/.test(raw)) { + const value = + raw === "" + ? 0 + : clampNonNegativeNumber(Number(raw)); + setPropertyTaxAmount(value); + const price = calculatedHomePrice || 0; + if (price > 0) + setPropertyTaxPercent((value / price) * 100); + } }} - 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" + aria-label="Property tax annual dollar amount" + 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" /> - $ +
)}
- {/* Homeowners Insurance */} + {/* Homeowners Insurance */}
- -
- - -
+ + Homeowners insurance (annual) + +
+ + Home insurance input type + +
+ + +
+
- {/* Home Insurance Amount Input */} - {homeInsuranceMode === 'percentage' ? ( + {homeInsuranceMode === "percentage" ? (
{ - const raw = e.target.value; - const value = clampNonNegativeNumber(raw === '' ? 0 : Number(raw)); + const value = clampNonNegativeNumber( + e.target.value === "" + ? 0 + : Number(e.target.value), + ); setHomeInsurancePercent(value); - // FIX: Use unrounded calculatedHomePrice - const price = calculatedHomePrice || 0; - setHomeInsuranceAmount((value / 100) * price); + setHomeInsuranceAmount( + (value / 100) * (calculatedHomePrice || 0), + ); }} + aria-label="Home insurance percentage" 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" /> - +
) : (
{ - const raw = e.target.value; - const value = clampNonNegativeNumber(raw === '' ? 0 : Number(raw)); - setHomeInsuranceAmount(value); - // FIX: Use unrounded calculatedHomePrice - const price = calculatedHomePrice || 0; - if (price > 0) setHomeInsurancePercent((value / price) * 100); + const raw = e.target.value.replace(/,/g, ""); + if (raw === "" || /^\d*$/.test(raw)) { + const value = + raw === "" + ? 0 + : clampNonNegativeNumber(Number(raw)); + setHomeInsuranceAmount(value); + const price = calculatedHomePrice || 0; + if (price > 0) + setHomeInsurancePercent( + (value / price) * 100, + ); + } }} - 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" + aria-label="Home insurance annual dollar amount" + 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" /> - $ +
)}
{/* HOA Dues */}
- +
- $ + { + const raw = e.target.value.replace(/,/g, ""); + if (raw === "") { + setHoaDues(""); + return; + } + const clamped = Math.min( + Math.max(0, Number(raw)), + 100000, + ); + setHoaDues(clamped.toString()); + }} placeholder="" step="1" min="0" max="100000" - value={hoaDues} - onChange={(e) => { - const value = Math.min(Number(e.target.value) || 0, 100000); - setHoaDues(value.toString()); - }} className="w-full pl-8 pr-4 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" />
+ + {/* Right Column - Results — Fix 3: aria-live */}
- {/* Right Column - Results */} - + {showAffordResults ? ( <> - Estimated home price + {/* Fix 7: explicit h2 */} + + Estimated home price +
-

0 - ? 'text-3xl font-bold text-lagunita' - : 'text-lg font-bold text-gray-500 italic' - }> - {results.homePrice > 0 ? formatCurrency(Number(results.homePrice)) : emptyResultsString} +

0 + ? "text-3xl font-bold text-lagunita" + : "text-lg font-bold text-gray-500 italic" + } + > + {results.homePrice > 0 + ? formatCurrency(Number(results.homePrice)) + : emptyResultsString}

-
-
+
Down payment:
@@ -730,77 +892,70 @@ export default function MortgageCalculator() {
-
+
Loan amount:
-
+
{formatCurrency(results.loanAmount || 0)}
-

Monthly breakdown

-
+

+ Monthly breakdown +

+
-
+
Mortgage payment:
-
+
{formatCurrency(results.monthlyMortgage || 0)}
-
+
Property taxes:
-
+
{formatCurrency(results.monthlyTax || 0)}
-
+
Insurance:
-
- {formatCurrency(results.monthlyInsurance || 0)} +
+ {formatCurrency( + results.monthlyInsurance || 0, + )}
-
+
HOA:
-
+
{formatCurrency(results.hoaDues || 0)}
-
+
Total monthly housing cost:
-
- {formatCurrency(results.totalMonthlyHousingCost || 0)} +
+ {formatCurrency( + results.totalMonthlyHousingCost || 0, + )}
-
- {/* Disclaimer */} -
-

- This estimate is based on the portion of your monthly budget allocated to principal and interest. Taxes, insurance, and HOA are shown separately. -

-
+
+

+ This estimate is based on the portion of your + monthly budget allocated to principal and + interest. Taxes, insurance, and HOA are shown + separately. +

@@ -814,108 +969,149 @@ export default function MortgageCalculator() {
+ {/* ── Tab 2: Payment ────────────────────────────────────────── */} -

Estimate a monthly mortgage payment based on your target home price.

+

+ Estimate a monthly mortgage payment based on your target home + price. +

- {/* Left Column - Inputs */}
+ {/* Home Price */}
-
- {/* Down Payment Monthly Payment */} + {/* Down Payment */}
- - {/* Radio Buttons for Payment Mode */} -
- - -
+ + Down payment + +
+ Down payment type +
+ + +
+
- {downPaymentMode === 'percentage' ? ( + {downPaymentMode === "percentage" ? (
{ const raw = e.target.value; setDownPaymentPercentInput(raw); - if (raw === '') { + if (raw === "") { setDownPaymentPercent(0); setDownPaymentAmount(0); - setDownPaymentAmountInput('0'); + setDownPaymentAmountInput("0"); return; } const value = clampPercent(Number(raw)); @@ -934,329 +1130,494 @@ export default function MortgageCalculator() { const price = Number(homePrice) || 0; const computedAmount = (value / 100) * price; setDownPaymentAmount(computedAmount); - setDownPaymentAmountInput(Math.round(computedAmount).toString()); + setDownPaymentAmountInput( + Math.round(computedAmount).toString(), + ); }} + aria-label="Down payment percentage" 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" /> - +
) : (
{ - const raw = e.target.value; - setDownPaymentAmountInput(raw); - if (raw === '') { - setDownPaymentAmount(0); - setDownPaymentPercent(0); - setDownPaymentPercentInput('0'); - return; + const raw = e.target.value.replace(/,/g, ""); + if (raw === "" || /^\d*$/.test(raw)) { + setDownPaymentAmountInput(raw); + if (raw === "") { + setDownPaymentAmount(0); + setDownPaymentPercent(0); + setDownPaymentPercentInput("0"); + return; + } + const maxPrice = Number(homePrice) || 0; + const clampedAmount = clampDownPaymentAmount( + Number(raw), + maxPrice, + ); + setDownPaymentAmount(clampedAmount); + const percentValue = + maxPrice > 0 + ? (clampedAmount / maxPrice) * 100 + : 0; + setDownPaymentPercent(percentValue); + setDownPaymentPercentInput( + percentValue.toFixed(2), + ); } - // FIX: Don't clamp immediately; let user type freely - // Only clamp the internal state value - const maxPrice = Number(homePrice) || 0; - const rawAmount = Number(raw); - const clampedAmount = clampDownPaymentAmount(rawAmount, maxPrice); - - // Store the clamped amount internally - setDownPaymentAmount(clampedAmount); - - // Calculate percentage based on clamped amount - const percentValue = maxPrice > 0 ? (clampedAmount / maxPrice) * 100 : 0; - setDownPaymentPercent(percentValue); - setDownPaymentPercentInput(percentValue.toFixed(2)); }} - 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" + aria-label="Down payment amount in dollars" + 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" /> - $ +
)} -

Enter 0 if no down payment is planned.

+

+ Enter 0 if no down payment is planned. +

{/* Interest Rate */}
- +
{ const raw = e.target.value; - if (raw === '') { - setInterestRate(''); - return; - } + if (raw === '') { setInterestRate(''); return; } setInterestRate(Math.max(0, Number(raw)).toString()); }} - className="w-full pr-8 pl-4 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" + onBlur={() => { + if (interestRate === '' && homePrice !== '') { + setShowRateError(true); + } else { + setShowRateError(false); + } + }} + aria-describedby={showRateError ? "interest-rate-payment-error" : undefined} + aria-invalid={showRateError} + className={`w-full pr-8 pl-4 py-3 border-2 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 ${showRateError ? "border-[var(--color-inline-error)] border-2" : ""}`} /> - % +
+ {showRateError && ( + + )}
- {/* Loan Term */} + {/* Loan Term — Fix 5: separate name from afford tab */}
- -
- - -
+
+ + Loan term + +
+ + +
+
{/* Optional Section */}
-

Optional

+

+ Additional housing costs +

- {/* Property Taxes */} + {/* Property Taxes */}
- - {/* Radio Buttons for Property Tax Mode */} -
- - -
+ + Property taxes (annual) + +
+ + Property tax input type + +
+ + +
+
- {propertyTaxMode === 'percentage' ? ( + {propertyTaxMode === "percentage" ? ( <> { - const raw = e.target.value; - const value = clampNonNegativeNumber(raw === '' ? 0 : Number(raw)); + const value = clampNonNegativeNumber( + e.target.value === "" + ? 0 + : Number(e.target.value), + ); setPropertyTaxPercent(value); - const price = Number(homePrice) || 0; - setPropertyTaxAmount((value / 100) * price); + setPropertyTaxAmount( + (value / 100) * (Number(homePrice) || 0), + ); }} + aria-label="Property tax percentage" 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" /> - + ) : ( <> { - const raw = e.target.value; - const value = clampNonNegativeNumber(raw === '' ? 0 : Number(raw)); - setPropertyTaxAmount(value); - const price = Number(homePrice) || 0; - if (price > 0) setPropertyTaxPercent((value / price) * 100); + const raw = e.target.value.replace(/,/g, ""); + if (raw === "" || /^\d*$/.test(raw)) { + const value = + raw === "" + ? 0 + : clampNonNegativeNumber(Number(raw)); + setPropertyTaxAmount(value); + const price = Number(homePrice) || 0; + if (price > 0) + setPropertyTaxPercent( + (value / price) * 100, + ); + } }} - 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" + aria-label="Property tax annual dollar amount" + 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" /> - $ + )}
- {/* Homeowners Insurance */} + {/* Homeowners Insurance */}
- - {/* Radio Buttons for Home Insurance Mode */} -
- - -
+ + Homeowners insurance (annual) + +
+ + Home insurance input type + +
+ + +
+
- {homeInsuranceMode === 'percentage' ? ( + {homeInsuranceMode === "percentage" ? ( <> { - const raw = e.target.value; - const value = clampNonNegativeNumber(raw === '' ? 0 : Number(raw)); + const value = clampNonNegativeNumber( + e.target.value === "" + ? 0 + : Number(e.target.value), + ); setHomeInsurancePercent(value); - const price = Number(homePrice) || 0; - setHomeInsuranceAmount((value / 100) * price); + setHomeInsuranceAmount( + (value / 100) * (Number(homePrice) || 0), + ); }} + aria-label="Home insurance percentage" 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" /> - + ) : ( <> { - const raw = e.target.value; - const value = clampNonNegativeNumber(raw === '' ? 0 : Number(raw)); - setHomeInsuranceAmount(value); - const price = Number(homePrice) || 0; - if (price > 0) setHomeInsurancePercent((value / price) * 100); + const raw = e.target.value.replace(/,/g, ""); + if (raw === "" || /^\d*$/.test(raw)) { + const value = + raw === "" + ? 0 + : clampNonNegativeNumber(Number(raw)); + setHomeInsuranceAmount(value); + const price = Number(homePrice) || 0; + if (price > 0) + setHomeInsurancePercent( + (value / price) * 100, + ); + } }} - 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" + aria-label="Home insurance annual dollar amount" + 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" /> - $ + )}
- {/* HOA Dues */} -
- -
- $ - { - const raw = e.target.value; - if (raw === '') { - setHoaDues(''); - return; - } - setHoaDues(Math.max(0, Number(raw)).toString()); - }} - className="w-full pl-8 pr-4 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" - /> -
+ {/* HOA Dues */} +
+ +
+ + { + const raw = e.target.value.replace(/,/g, ""); + if (raw === "") { + setHoaDues(""); + return; + } + setHoaDues(Math.max(0, Number(raw)).toString()); + }} + placeholder="" + step="1" + min="0" + className="w-full pl-8 pr-4 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" + />
+
- {/* Right Column - Results */} -
- {/* Right Column - Results */} - + {/* Right Column - Results — Fix 3: aria-live */} +
+ {showPaymentResults ? ( <> - Estimated monthly mortgage payment + + Estimated monthly mortgage payment +
-

0 - ? 'text-3xl font-bold text-lagunita' - : 'text-lg font-bold text-gray-500 italic' - }> - {results.homePrice > 0 ? formatCurrency(Number(results.monthlyMortgage)) : emptyResultsString} +

0 + ? "text-3xl font-bold text-lagunita" + : "text-lg font-bold text-gray-500 italic" + } + > + {results.homePrice > 0 + ? formatCurrency( + Number(results.monthlyMortgage), + ) + : emptyResultsString}

-
-
+
Down payment:
@@ -1264,77 +1625,68 @@ export default function MortgageCalculator() {
-
+
Loan amount:
-
+
{formatCurrency(results.loanAmount)}
-

Monthly breakdown

-
+

+ Monthly breakdown +

+
-
+
Mortgage payment:
-
+
{formatCurrency(results.monthlyMortgage)}
-
+
Property taxes:
-
+
{formatCurrency(results.monthlyTax)}
-
+
Insurance:
-
+
{formatCurrency(results.monthlyInsurance)}
-
+
HOA:
-
+
{formatCurrency(results.hoaDues)}
-
+
Total monthly housing cost:
-
- {formatCurrency(results.totalMonthlyHousingCost)} +
+ {formatCurrency( + results.totalMonthlyHousingCost, + )}
-
- {/* Disclaimer */} -
-

- This estimate shows your monthly mortgage payment based on the loan amount, interest rate, and term. Taxes, insurance, and HOA are shown separately. -

-
+
+

+ This estimate shows your monthly mortgage + payment based on the loan amount, interest + rate, and term. Taxes, insurance, and HOA are + shown separately. +

@@ -1344,13 +1696,14 @@ export default function MortgageCalculator() {
)} -
- - {/* Calculate and Reset Buttons */} + + + {/* Fix 8: type="button" to prevent accidental form submit */}
); -} \ No newline at end of file +} diff --git a/app/interactives/mortgage-refinancing-calculator-v2/page.tsx b/app/interactives/mortgage-refinancing-calculator-v2/page.tsx index a1f275b..248614b 100644 --- a/app/interactives/mortgage-refinancing-calculator-v2/page.tsx +++ b/app/interactives/mortgage-refinancing-calculator-v2/page.tsx @@ -15,6 +15,11 @@ const formatCurrency = (amount: number) => maximumFractionDigits: 2, }) +const formatNumberWithCommas = (value: string): string => { + const num = value.replace(/,/g, "") + return num.replace(/\B(?=(\d{3})+(?!\d))/g, ",") +} + export default function MortgageCalculator() { const [activeTab, setActiveTab] = useState("current-balance") @@ -41,6 +46,7 @@ export default function MortgageCalculator() { totalSavings: number breakEvenMonths: number breakEvenMessage: string + closingCosts: number } | null>(null) const [refErrors, setRefErrors] = useState<{ @@ -113,9 +119,7 @@ export default function MortgageCalculator() { if (Object.keys(errors).length > 0) return; - const currentMonthlyPayment = currentR === 0 - ? currentBal / currentM - : (currentBal * (currentR * Math.pow(1 + currentR, currentM))) / (Math.pow(1 + currentR, currentM) - 1) + const currentMonthlyPayment = Number.parseFloat(refCurrentMonthlyPayment) || 0 const newMonthlyPayment = newR === 0 ? newLoanAmount / newM @@ -127,11 +131,9 @@ export default function MortgageCalculator() { if (monthsInHouse > 0) { // ── Planned-stay comparison ───────────────────────────────── - // Months actually used for each loan (can't exceed the loan term) const currentMonthsToUse = Math.min(monthsInHouse, currentM) const newMonthsToUse = Math.min(monthsInHouse, newM) - // Helper: remaining principal after n payments const remainingBalance = ( principal: number, monthlyRate: number, @@ -154,16 +156,12 @@ export default function MortgageCalculator() { ); }; - // Total paid out of pocket over the planned stay const totalCurrentPaid = currentMonthlyPayment * currentMonthsToUse const totalNewPaid = newMonthlyPayment * newMonthsToUse + closingCosts - // Remaining principal at point of sale — higher balance = more owed at sale const currentRemainingBal = remainingBalance(currentBal, currentR, currentM, currentMonthsToUse) const newRemainingBal = remainingBalance(newLoanAmount, newR, newM, newMonthsToUse) - // Net cost = what you paid + what you still owe at sale - // Lower net cost = better deal const currentNetCost = totalCurrentPaid + currentRemainingBal const newNetCost = totalNewPaid + newRemainingBal @@ -179,7 +177,7 @@ export default function MortgageCalculator() { breakEvenMonths = totalSavings > 0 ? 0 : Infinity } - const breakEvenMessage = breakEvenMonths === Infinity + const breakEvenMessage = breakEvenMonths === Infinity ? "You will not break even with this refinance" : `Break even in ${Math.ceil(breakEvenMonths)} months` @@ -192,6 +190,7 @@ export default function MortgageCalculator() { totalSavings, breakEvenMonths, breakEvenMessage, + closingCosts, }) } else { @@ -204,7 +203,6 @@ export default function MortgageCalculator() { if (monthlySavings > 0) { breakEvenMonths = closingCosts > 0 ? closingCosts / monthlySavings : 0 - } else if (monthlySavings < 0) { if (totalSavings > 0) { breakEvenMonths = closingCosts > 0 ? closingCosts / (-monthlySavings) : 0 @@ -215,7 +213,7 @@ export default function MortgageCalculator() { breakEvenMonths = totalSavings > 0 ? 0 : Infinity } - const breakEvenMessage = breakEvenMonths === Infinity + const breakEvenMessage = breakEvenMonths === Infinity ? "You will not break even with this refinance" : `Break even in ${Math.ceil(breakEvenMonths)} months` @@ -228,6 +226,7 @@ export default function MortgageCalculator() { totalSavings, breakEvenMonths, breakEvenMessage, + closingCosts, }) } } @@ -277,20 +276,19 @@ export default function MortgageCalculator() { onChange={(e) => setMonthsRemaining(e.target.value)} min="0" step="1" + aria-describedby={monthsRemaining ? "months-hint" : undefined} className="pr-16 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> {monthsRemaining && ( - + )}
{monthsRemaining && ( -

+

{monthsRemaining} months ={" "} - {(Number.parseFloat(monthsRemaining) / 12).toFixed( - 1, - )}{" "} + {(Number.parseFloat(monthsRemaining) / 12).toFixed(1)}{" "} years

)} @@ -304,6 +302,7 @@ export default function MortgageCalculator() {
- {/* Reset — only visible once there's a result */} {currentBalance !== null && ( - + )}
@@ -390,17 +391,14 @@ export default function MortgageCalculator() {

Estimated current balance

-

+

${formatCurrency(currentBalance)}

-

- Based on the remaining monthly payments -

) : ( @@ -436,9 +434,9 @@ export default function MortgageCalculator() { then come back here to explore refinance options.

@@ -449,7 +447,7 @@ export default function MortgageCalculator() {
{/* Current Loan Terms — read-only */}
-

+

Current Loan Terms

{currentBalance !== null ? ( @@ -486,15 +484,15 @@ export default function MortgageCalculator() { Monthly payment:
- ${refCurrentMonthlyPayment} + ${formatCurrency( + Number.parseFloat(refCurrentMonthlyPayment) || 0, + )}
{refErrors.newRate && (
-
-
+

Optional

@@ -674,7 +695,7 @@ export default function MortgageCalculator() { step="1" className="pr-14 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> - +
@@ -684,12 +705,12 @@ export default function MortgageCalculator() { {refinanceResults ? (
- + Reset new loan terms
) : (
- $ - {formatCurrency( - Number.parseFloat(refClosingCosts) || 0, - )} + ${formatCurrency(refinanceResults.closingCosts)}
-
+
{refinanceResults.totalSavings >= 0 ? "Estimated savings over planned stay:" : "Estimated total cost difference:"}
- $ - {formatCurrency( - Math.abs(refinanceResults.totalSavings), - )} + ${formatCurrency(Math.abs(refinanceResults.totalSavings))}
@@ -819,4 +832,4 @@ export default function MortgageCalculator() {
); -} \ No newline at end of file +} diff --git a/app/interactives/present-value-calculator-v2/page.tsx b/app/interactives/present-value-calculator-v2/page.tsx index a0b0362..c9327ab 100644 --- a/app/interactives/present-value-calculator-v2/page.tsx +++ b/app/interactives/present-value-calculator-v2/page.tsx @@ -1,9 +1,10 @@ "use client" -import { useState, useMemo } from "react" +import { useState, useMemo, useEffect } from "react" import { Input } from "@/app/ui/components/input" import { Label } from "@/app/ui/components/label" +import { Button } from "@/app/ui/components/button" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/ui/components/tabs" import { Select, @@ -13,6 +14,8 @@ import { SelectValue, } from "@/app/ui/components/select" import ThemeToggle from "@/app/lib/theme-toggle" +import { FaCircleInfo } from "react-icons/fa6" + type CompoundingFrequency = "annually" | "semi-annually" | "quarterly" | "monthly" | "biweekly" | "weekly" | "daily" @@ -26,81 +29,171 @@ const frequencyMap: Record = { + annually: "years", + "semi-annually": "semi-annual periods", + quarterly: "quarters", + monthly: "months", + biweekly: "bi-weekly periods", + weekly: "weeks", + daily: "days", +} + +const freqLabels: Record = { + annually: "annual", + "semi-annually": "semiannual", + quarterly: "quarterly", + monthly: "monthly", + biweekly: "biweekly", + weekly: "weekly", + daily: "daily", +} + +function buildPeriodsRangeError(compounding: CompoundingFrequency, max: number): string { + const label = periodPluralLabels[compounding] + const maxFormatted = max.toLocaleString("en-US") + const base = `Enter a number of ${label} between 0 and ${maxFormatted}.` + if (compounding === "annually") return base + return `${base} (${maxFormatted} periods = 100 years with ${freqLabels[compounding]} compounding).` +} + +function suppressNegativeZero(value: number): number { + return Object.is(value, -0) ? 0 : value +} + export default function PresentValueCalculator() { const [activeTab, setActiveTab] = useState("single") - + // Single Amount State - const [futureValue, setFutureValue] = useState(0) - const [interestRate, setInterestRate] = useState(0) - const [timePeriod, setTimePeriod] = useState(0) + const [futureValue, setFutureValue] = useState("") + const [interestRate, setInterestRate] = useState("") + const [timePeriod, setTimePeriod] = useState("") const [compoundingFrequency, setCompoundingFrequency] = useState("annually") // Payment Series State - const [paymentAmount, setPaymentAmount] = useState(0) - const [paymentInterestRate, setPaymentInterestRate] = useState(0) - const [numberOfPayments, setNumberOfPayments] = useState(0) + const [paymentAmount, setPaymentAmount] = useState("") + const [paymentInterestRate, setPaymentInterestRate] = useState("") + const [numberOfPayments, setNumberOfPayments] = useState("") const [paymentFrequency, setPaymentFrequency] = useState("annually") - const [finalAmount, setFinalAmount] = useState(0) + const [finalAmount, setFinalAmount] = useState("") // 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 [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("") + + // Warning states (amber — calc still runs) + const [interestRateWarning, setInterestRateWarning] = useState("") + const [paymentInterestRateWarning, setPaymentInterestRateWarning] = useState("") + + // Derived max periods for each tab + const singleMaxPeriods = frequencyMap[compoundingFrequency].periods * 100 + const seriesMaxPeriods = frequencyMap[paymentFrequency].periods * 100 + + // Whether each tab has a blocking error + const singleHasError = !!futureValueError || !!interestRateError || !!timePeriodError + const seriesHasError = !!paymentAmountError || !!finalAmountError || !!paymentInterestRateError || !!numberOfPaymentsError + + // Debounced inputs for calculations — updates after 300ms pause in typing + const [debouncedSingle, setDebouncedSingle] = useState({ + futureValue: "", + interestRate: "", + timePeriod: "", + compoundingFrequency: "annually" as CompoundingFrequency, + }) + useEffect(() => { + const t = setTimeout( + () => setDebouncedSingle({ futureValue, interestRate, timePeriod, compoundingFrequency }), + 300, + ) + return () => clearTimeout(t) + }, [futureValue, interestRate, timePeriod, compoundingFrequency]) + + const [debouncedSeries, setDebouncedSeries] = useState({ + paymentAmount: "", + paymentInterestRate: "", + numberOfPayments: "", + paymentFrequency: "annually" as CompoundingFrequency, + finalAmount: "", + }) + useEffect(() => { + const t = setTimeout( + () => setDebouncedSeries({ paymentAmount, paymentInterestRate, numberOfPayments, paymentFrequency, finalAmount }), + 300, + ) + return () => clearTimeout(t) + }, [paymentAmount, paymentInterestRate, numberOfPayments, paymentFrequency, finalAmount]) + + const resetSingle = () => { + setFutureValue("") + setInterestRate("") + setTimePeriod("") + setCompoundingFrequency("annually") + setFutureValueError("") + setInterestRateError("") + setTimePeriodError("") + setInterestRateWarning("") + } + + const resetSeries = () => { + setPaymentAmount("") + setPaymentInterestRate("") + setNumberOfPayments("") + setPaymentFrequency("annually") + setFinalAmount("") + setPaymentAmountError("") + setPaymentInterestRateError("") + setNumberOfPaymentsError("") + setFinalAmountError("") + setPaymentInterestRateWarning("") + } const singleCalculations = useMemo(() => { - const rate = interestRate / 100 - const n = frequencyMap[compoundingFrequency].periods - const totalPeriods = timePeriod // CHANGED: use input directly as total periods + const fv = parseFloat(debouncedSingle.futureValue) || 0 + const rate = (parseFloat(debouncedSingle.interestRate) || 0) / 100 + const n = frequencyMap[debouncedSingle.compoundingFrequency].periods + const totalPeriods = parseFloat(debouncedSingle.timePeriod) || 0 const periodRate = rate / n - const presentValue = futureValue / Math.pow(1 + periodRate, totalPeriods) - const discountAmount = futureValue - presentValue + const presentValue = fv / Math.pow(1 + periodRate, totalPeriods) + const discountAmount = fv - presentValue return { presentValue, discountAmount, totalPeriods, } - }, [futureValue, interestRate, timePeriod, compoundingFrequency]) + }, [debouncedSingle]) const paymentCalculations = useMemo(() => { - const rate = paymentInterestRate / 100; - const n = frequencyMap[paymentFrequency].periods; - const periodRate = rate / n; + const rate = (parseFloat(debouncedSeries.paymentInterestRate) || 0) / 100 + const n = frequencyMap[debouncedSeries.paymentFrequency].periods + const periods = parseFloat(debouncedSeries.numberOfPayments) || 0 + const pa = parseFloat(debouncedSeries.paymentAmount) || 0 + const fa = parseFloat(debouncedSeries.finalAmount) || 0 + const periodRate = rate / n - let pvPayments: number; + let pvPayments: number if (periodRate === 0) { - pvPayments = paymentAmount * numberOfPayments; + pvPayments = pa * periods } else { - pvPayments = - paymentAmount * - ((1 - Math.pow(1 + periodRate, -numberOfPayments)) / periodRate); + pvPayments = pa * ((1 - Math.pow(1 + periodRate, -periods)) / periodRate) } - // 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; + const pvFinalAmount = fa / Math.pow(1 + periodRate, periods) + const presentValue = pvPayments + pvFinalAmount + const totalPayments = pa * periods + fa + const discountAmount = totalPayments - presentValue return { presentValue, totalPayments, discountAmount, - }; - }, [ - paymentAmount, - finalAmount, - paymentInterestRate, - numberOfPayments, - paymentFrequency, - ]); + } + }, [debouncedSeries]) const formatCurrency = (value: number) => { return new Intl.NumberFormat("en-US", { @@ -114,7 +207,6 @@ export default function PresentValueCalculator() { return (
- {/* Header */}

Present Value Calculator

@@ -132,10 +224,11 @@ export default function PresentValueCalculator() {
- {/* Future Value Input */}

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

+ + {/* Future Value */}
- {/* Interest Rate Input */} + {/* Interest Rate */}
@@ -221,9 +366,14 @@ export default function PresentValueCalculator() { {interestRateError}

)} + {interestRateWarning && !interestRateError && ( +

+ {interestRateWarning} +

+ )}
- {/* Time Period Input */} + {/* Time Period */}
- {/* Results Section */} + {/* Results */}
- {/* Main Present Value Display */}

Present value

- {formatCurrency(singleCalculations.presentValue)} + {singleHasError ? "—" : formatCurrency(singleCalculations.presentValue)}

- - {/* Breakdown */}
- {formatCurrency(futureValue)} + {singleHasError ? "—" : formatCurrency(parseFloat(futureValue) || 0)}
@@ -319,9 +504,9 @@ export default function PresentValueCalculator() { Discount amount:
- {formatCurrency(singleCalculations.discountAmount)} + {singleHasError ? "—" : formatCurrency(suppressNegativeZero(singleCalculations.discountAmount))}
@@ -333,18 +518,19 @@ export default function PresentValueCalculator() {
- {/* 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. + Find what a series of payments is worth today. Enter the + payment per period and number of periods (payments) to + calculate the present value.

+ + {/* Payment Amount */}
{ - const val = - e.target.value === "" ? 0 : Number(e.target.value); - if (val < 0) { - setPaymentAmountError( - "Payment amount must be greater than 0.", - ); - return; + const raw = e.target.value + if (raw === "") { + setPaymentAmountError("") + setPaymentAmount("") + return + } + const val = Number(raw) + if (val < 0 || val > 100_000_000) { + setPaymentAmountError("Enter an amount between $0 and $100,000,000.") + setPaymentAmount(raw) + return + } + setPaymentAmountError("") + setPaymentAmount(raw) + }} + onBlur={(e) => { + const raw = e.target.value + const val = parseFloat(raw) + if (raw === "" || isNaN(val)) { + setPaymentAmount("") + } else if (val < 0 || val > 100_000_000) { + setPaymentAmount(String(val)) + } else { + setPaymentAmountError("") + setPaymentAmount(String(val)) } - 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" : ""}`} + 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 ${parseFloat(paymentAmount) > 0 ? "pl-7" : "pl-8"} ${paymentAmountError ? "border-[var(--color-inline-error)] border-2" : ""}`} />
{paymentAmountError && ( @@ -383,14 +585,33 @@ export default function PresentValueCalculator() { )}
- {/* Final Amount Input */} + {/* Final Amount */}
- +
+ +
+ + +
+
{finalAmountError && ( @@ -428,7 +669,7 @@ export default function PresentValueCalculator() { )}
- {/* Interest Rate Input */} + {/* Payment Interest Rate */}
- {/* Number of Payments Input */} + {/* Number of Payments */}
{ - const val = - e.target.value === "" ? 0 : Number(e.target.value); - if (val < 1) { - setNumberOfPaymentsError( - "Number of payments must be greater than 0.", - ); - return; + const raw = e.target.value + if (raw === "") { + setNumberOfPayments("") + setNumberOfPaymentsError("") + return + } + const val = Number(raw) + if (val < 0 || val > seriesMaxPeriods) { + setNumberOfPayments("") + setNumberOfPaymentsError(buildPeriodsRangeError(paymentFrequency, seriesMaxPeriods)) + return + } + setNumberOfPaymentsError("") + setNumberOfPayments(raw) + }} + onBlur={(e) => { + const raw = e.target.value + if (raw === "") { + if (!numberOfPaymentsError) { + setTimeout(() => setNumberOfPaymentsError("Please enter the number of payments."), 150) + } + } else { + const val = parseFloat(raw) + if (!isNaN(val)) setNumberOfPayments(String(val)) } - 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} + min={0} + max={seriesMaxPeriods} step={1} /> {numberOfPaymentsError && ( @@ -521,16 +807,26 @@ export default function PresentValueCalculator() { {/* Payment Frequency */}
-
- {/* Results Section */} + {/* Results */}

Present value

- {formatCurrency(paymentCalculations.presentValue)} + {seriesHasError ? "—" : formatCurrency(paymentCalculations.presentValue)}

- - {/* Breakdown */} + {seriesHasError && ( +

+ Fix the fields on the left to see results. +

+ )}
Total payments:
- {formatCurrency(paymentCalculations.totalPayments)} + {seriesHasError ? "—" : formatCurrency(paymentCalculations.totalPayments)}
@@ -570,9 +882,9 @@ export default function PresentValueCalculator() { Discount amount:
- {formatCurrency(paymentCalculations.discountAmount)} + {seriesHasError ? "—" : formatCurrency(suppressNegativeZero(paymentCalculations.discountAmount))}
@@ -583,5 +895,5 @@ export default function PresentValueCalculator() {
- ); -} \ No newline at end of file + ) +} diff --git a/app/interactives/retirement-calculator/page.tsx b/app/interactives/retirement-calculator/page.tsx index 878d8df..d66697b 100644 --- a/app/interactives/retirement-calculator/page.tsx +++ b/app/interactives/retirement-calculator/page.tsx @@ -3,13 +3,13 @@ import { useState, useMemo } from "react" import ThemeToggle from "@/app/lib/theme-toggle"; import { Tabs, TabsList, TabsTrigger } from "@/app/ui/components/tabs"; -import { BiSolidError } from "react-icons/bi"; +import InfoPopover from "@/app/ui/components/popover"; interface CalculatorInputs { annualSpending: number retirementLength: number - expectedReturnDuringRetirement: number // Used for Required Balance calculation - expectedReturnBeforeRetirement: number // Used ONLY for Annual Savings calculation + expectedReturnDuringRetirement: number + expectedReturnBeforeRetirement: number currentSavings: number yearsToRetirement: number } @@ -23,10 +23,15 @@ function formatCurrency(value: number): string { }).format(value) } +function formatNumberWithCommas(value: string): string { + const num = value.replace(/,/g, "") + return num.replace(/\B(?=(\d{3})+(?!\d))/g, ",") +} + const defaultInputs: CalculatorInputs = { - annualSpending: 60000, - retirementLength: 25, - expectedReturnDuringRetirement: 5, + annualSpending: 0, + retirementLength: 0, + expectedReturnDuringRetirement: 0, expectedReturnBeforeRetirement: 0, currentSavings: 0, yearsToRetirement: 0, @@ -35,96 +40,99 @@ const defaultInputs: CalculatorInputs = { export default function RetirementCalculator() { const [activeTab, setActiveTab] = useState<"balance" | "savings">("balance") const [inputs, setInputs] = useState(defaultInputs) - const [isCalculated, setIsCalculated] = useState(false) const [frozenRequiredBalance, setFrozenRequiredBalance] = useState(0) - const results = useMemo(() => { - const realReturnDuringRetirement = inputs.expectedReturnDuringRetirement / 100 // ← CHANGED - const realReturnBeforeRetirement = inputs.expectedReturnBeforeRetirement / 100 // ← ADDED - - // Calculate Required Balance based on retirement spending needs - let calculatedRequiredBalance: number - - if (realReturnDuringRetirement <= 0) { // ← CHANGED - calculatedRequiredBalance = inputs.annualSpending * inputs.retirementLength - } else { - calculatedRequiredBalance = - inputs.annualSpending * - ((1 - Math.pow(1 + realReturnDuringRetirement, -inputs.retirementLength)) / realReturnDuringRetirement) // ← CHANGED + // 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) { + return inputs.annualSpending * inputs.retirementLength } + return ( + inputs.annualSpending * + ((1 - Math.pow(1 + rate, -inputs.retirementLength)) / rate) + ) + }, [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 + + const results = useMemo(() => { + const realReturnBeforeRetirement = inputs.expectedReturnBeforeRetirement / 100 - // Use frozen value for Annual Savings tab, fresh calculation for Required Balance tab + // Fix 2: Use frozen value for savings tab, fresh calculation for balance tab const requiredBalance = activeTab === "savings" ? frozenRequiredBalance : calculatedRequiredBalance - // Now calculate target balance and annual savings based on the appropriate required balance - const FV_currentSavings = inputs.currentSavings * Math.pow(1 + realReturnBeforeRetirement, inputs.yearsToRetirement) // ← CHANGED + 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) { // ← CHANGED + if (realReturnBeforeRetirement <= 0 || inputs.yearsToRetirement <= 0) { annualSavings = inputs.yearsToRetirement > 0 ? targetBalance / inputs.yearsToRetirement : 0 } else { annualSavings = targetBalance * - (realReturnBeforeRetirement / (Math.pow(1 + realReturnBeforeRetirement, inputs.yearsToRetirement) - 1)) // ← CHANGED + (realReturnBeforeRetirement / (Math.pow(1 + realReturnBeforeRetirement, inputs.yearsToRetirement) - 1)) + } + + const calculateFrequencySavings = (frequency: number) => { + if (realReturnBeforeRetirement <= 0 || inputs.yearsToRetirement <= 0) { + return inputs.yearsToRetirement > 0 ? targetBalance / (inputs.yearsToRetirement * frequency) : 0 + } + const periodRate = Math.pow(1 + realReturnBeforeRetirement, 1 / frequency) - 1 + const totalPeriods = inputs.yearsToRetirement * frequency + return targetBalance * (periodRate / (Math.pow(1 + periodRate, totalPeriods) - 1)) } + const monthlySavings = calculateFrequencySavings(12) + const biWeeklySavings = calculateFrequencySavings(26) + const weeklySavings = calculateFrequencySavings(52) + return { requiredBalance: Math.round(requiredBalance), targetBalance: Math.round(targetBalance), annualSavings: Math.round(annualSavings), - monthlySavings: Math.round(annualSavings / 12), + monthlySavings: Math.round(monthlySavings), + biWeeklySavings: Math.round(biWeeklySavings), + weeklySavings: Math.round(weeklySavings), + fvCurrentSavings: Math.round(FV_currentSavings), } - }, [inputs, activeTab, frozenRequiredBalance]) + }, [inputs, activeTab, frozenRequiredBalance, calculatedRequiredBalance]) const updateInput = (key: keyof CalculatorInputs, value: string) => { const numValue = parseFloat(value) || 0 setInputs((prev) => ({ ...prev, [key]: numValue })) - - // Inputs that ONLY affect the savings calculation (don't reset isCalculated) - const savingsOnlyInputs: Array = [ - "currentSavings", - "yearsToRetirement", - "expectedReturnBeforeRetirement" // ← ADDED - ] - - // Inputs that affect the Required Balance calculation + const balanceInputs: Array = [ - "annualSpending", - "retirementLength", - "expectedReturnDuringRetirement" // ← ADDED + "annualSpending", + "retirementLength", + "expectedReturnDuringRetirement", ] - - // Don't reset if changing savings-only inputs - if (savingsOnlyInputs.includes(key)) return - - // Don't reset if on savings tab and changing balance inputs - if (activeTab === "savings" && balanceInputs.includes(key)) return - - setIsCalculated(false) - } - const handleCalculate = () => { - setIsCalculated(true) - // FREEZE the required balance when Calculate is clicked - const realReturn = inputs.expectedReturnDuringRetirement / 100 // ← CHANGED - let calculatedRequiredBalance: number - - if (realReturn <= 0) { - calculatedRequiredBalance = inputs.annualSpending * inputs.retirementLength - } else { - calculatedRequiredBalance = - inputs.annualSpending * - ((1 - Math.pow(1 + realReturn, -inputs.retirementLength)) / realReturn) + 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) { + calculatedFrozen = spendingVal * lengthVal + } else { + calculatedFrozen = + spendingVal * + ((1 - Math.pow(1 + rateVal, -lengthVal)) / rateVal) + } + + setFrozenRequiredBalance(Math.round(calculatedFrozen)) } - - setFrozenRequiredBalance(Math.round(calculatedRequiredBalance)) } const handleReset = () => { setInputs(defaultInputs) - setIsCalculated(false) setActiveTab("balance") setFrozenRequiredBalance(0) } @@ -133,101 +141,166 @@ export default function RetirementCalculator() {
- {/* Header */} -

- Understanding retirement planning -

- - {/* Tabs */} - { - if (v === "savings" && !isCalculated) return - setActiveTab(v as "balance" | "savings") - if (v === "balance") { - setIsCalculated(false) // This allows recalculation on the balance tab - setFrozenRequiredBalance(0) // Clear frozen value when returning to balance tab - } - }} className="mb-10"> + {/* Fix 6: proper h1 */} +

Retirement Planning Calculator

+ + { + setActiveTab(v as "balance" | "savings"); + if (v === "balance") { + setFrozenRequiredBalance(0); + } + }} + className="mb-10" + > - + Required balance - + Annual savings - {/* Main Content */}
{/* Left Column - Inputs */}
- {!isCalculated && ( -
- -

- First calculate your required retirement balance before viewing the Annual savings tab. -

-
- )} - {activeTab === "balance" ? ( <> - {/* Required Balance Result */} - {/* Annual Spending Input */} +
+

+ Step 1: Estimate the required retirement balance +

+
+ + {/* Annual Spending */}
-
+ + {/* Retirement Length */} +
+
updateInput("annualSpending", e.target.value)} + 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" /> + {/* Fix 3: aria-hidden on decorative suffix */} +
-

- How much you plan to withdraw each year. -

+

How many years your retirement will last.

+
+ + {/* Expected Return During Retirement */} +
+
+ + + Enter the expected rate of return on investments during + retirement. To account for inflation, use a real return + rate. For example, if expecting a 6% nominal return, and 3% + inflation, use a 3% real return. + +
+
+ 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" + /> + +
+

Annual investment return rate during retirement.

) : ( <> - {/* Annual Savings Input */} + {frozenRequiredBalance === 0 && ( +
+

+ Start by calculating the required retirement balance, then + return here to see how much to save to reach the goal. +

+
+ )} +
+

+ Step 2: Calculate savings needed to reach the required balance +

+
+ + {/* Current Savings */}
-
- {/* Years to Retirement Input */} + {/* Years to Retirement */}
-
- - )} - {/* Retirement Length Input */} - {activeTab === "balance" &&
- -
- 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" - /> -
-

- How many years your retirement will last. -

-
} - - {/* Expected Return Input - BALANCE TAB */} - {activeTab === "balance" && ( -
- -
- 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" - /> -
-

- Annual investment return rate during retirement. -

-
- )} - -{/* Expected Return Input - SAVINGS TAB */} -{activeTab === "savings" && ( -
- -
- 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" - /> -
-

- Annual investment return rate before retirement. -

-
-)} - - {/* Calculate and Reset Buttons */} - - {activeTab === "balance" && ( - <> -
- - + {/* Expected Return Before Retirement */} +
+
+ + + Enter the expected annual return on investments before + retirement. To account for inflation, use a real return. For + example, if expecting a 8% nominal return, and 3% inflation, + use a 5% real return. + +
+
+ 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" + /> + +
+

Annual investment return rate before retirement.

)} + {/* Reset Button — Fix 4: type="button" */} +
+ +
{/* Right Column - Results */} -
+
{activeTab === "balance" ? ( <> - {/* Required Balance Result */} -
-

- Required Retirement Balance -

-

- {isCalculated ? formatCurrency(results.requiredBalance) : "—"} -

-

- This estimates the lump sum needed at retirement to fund your annual spending for {inputs.retirementLength} years, assuming a {inputs.expectedReturnDuringRetirement}% annual return during retirement. -

-
+ {/* Fix 6: h2 for result heading */} +

Required Retirement Balance

+ + {/* Fix 2: Balance tab no longer checks fvCurrentSavings — that's a savings tab concern */} + {showBalanceResults ? ( + <> +

+ {formatCurrency(calculatedRequiredBalance)} +

+

+ This estimates the lump sum needed at retirement to fund{" "} + {formatCurrency(inputs.annualSpending)} per year for{" "} + {inputs.retirementLength} years, assuming a{" "} + {inputs.expectedReturnDuringRetirement}% annual return during + retirement. +

+
+ {/* Fix 5: type="button" */} + +
+ + ) : ( +
+

+ The required retirement balance will appear here. Enter + annual retirement spending, expected retirement length + (years), and expected annual return during retirement. +

+
+ )} ) : ( <> - {/* Annual Savings Result */} -
-

Required Retirement Balance

-

- {isCalculated ? formatCurrency(results.requiredBalance) : "—"} -

-

- This estimates the lump sum needed at retirement to fund your annual spending - for {inputs.retirementLength} years, assuming a {inputs.expectedReturnDuringRetirement}% - annual return during retirement. -

-

- Required Annual Savings -

-

- {formatCurrency(results.annualSavings)} -

-

- Amount to save each year over {inputs.yearsToRetirement} years to reach your - target balance, assuming a {inputs.expectedReturnBeforeRetirement}% annual - return before retirement. -

-
+

Required Retirement Balance

+

+ {frozenRequiredBalance > 0 ? formatCurrency(results.requiredBalance) : "—"} +

+

+ This estimates the lump sum needed at retirement to fund{" "} + {formatCurrency(inputs.annualSpending)} per year for{" "} + {inputs.retirementLength} years, assuming a{" "} + {inputs.expectedReturnDuringRetirement}% annual return during + retirement. +

+ + {frozenRequiredBalance > 0 && results.fvCurrentSavings >= results.requiredBalance ? ( +
+

Good news!

+

+ With an expected {inputs.expectedReturnBeforeRetirement}% annual + return, the current retirement savings of{" "} + {formatCurrency(inputs.currentSavings)} are projected to{" "} + {results.fvCurrentSavings > results.requiredBalance ? "exceed" : "reach"}{" "} + the target retirement balance with no additional contributions. + {results.fvCurrentSavings > results.requiredBalance && ( + <> +
+ Estimated balance at retirement:{" "} + {formatCurrency(results.fvCurrentSavings)} + + )} +

+
+ ) : frozenRequiredBalance > 0 ? ( + <> +

Required Annual Savings

+

+ {formatCurrency(results.annualSavings)} +

+

+ Amount to save each year over {inputs.yearsToRetirement} years + to reach your target balance, assuming a{" "} + {inputs.expectedReturnBeforeRetirement}% annual return before + retirement. +

+ +
+

Savings at Different Frequencies

+
+
+ Monthly + + {formatCurrency(results.monthlySavings)} + +
+
+ Bi-weekly + + {formatCurrency(results.biWeeklySavings)} + +
+
+ Weekly + + {formatCurrency(results.weeklySavings)} + +
+
+
+ + ) : null} )}
- ) + ); } diff --git a/app/interactives/time-value-money-calculator/app.tsx b/app/interactives/time-value-money-calculator/app.tsx index 74628ac..b226a11 100644 --- a/app/interactives/time-value-money-calculator/app.tsx +++ b/app/interactives/time-value-money-calculator/app.tsx @@ -14,6 +14,8 @@ import { } from "@/app/ui/components/select" import { Info, ChevronDown, ChevronUp } from "lucide-react" import ThemeToggle from "@/app/lib/theme-toggle" +import { FaPlus, FaMinus } from "react-icons/fa6" + type SolveFor = "FV" | "PV" | "PMT" | "RATE" | "NPER" type PaymentTiming = "end" | "beginning" @@ -64,8 +66,8 @@ export function TVMCalculator() { const [result, setResult] = useState(null) const [calcError, setCalcError] = useState("") const [fieldErrors, setFieldErrors] = useState([]) + const [signError, setSignError] = useState("") const [showHowToUse, setShowHowToUse] = useState(false) - const [showExamples, setShowExamples] = useState(false) const [exampleMode, setExampleMode] = useState<"saving" | "borrowing">("saving") const loadExample = (example: { @@ -149,9 +151,12 @@ export function TVMCalculator() { const getFieldError = (field: string): string | undefined => fieldErrors.find(e => e.field === field)?.message + const displayError = calcError || signError + const calculate = useCallback(() => { setCalcError("") - + setSignError("") + // ── Don't calculate (or show errors) until all required fields are filled ── const requiredFields: Record = { FV: [presentValue, annualRate, periods], @@ -189,6 +194,20 @@ export function TVMCalculator() { const timingMultiplier = paymentTiming === "beginning" ? (1 + ratePerPeriod) : 1 try { + // Require opposite signs for RATE/NPER solves: at least one cash flow must have opposite sign. + // When pmt === 0, pv and fv alone must have opposite signs. + if ((solveFor === "RATE" || solveFor === "NPER")) { + const allPositive = pmt === 0 ? (pv > 0 && fv > 0) : (pv > 0 && pmt > 0 && fv > 0) + const allNegative = pmt === 0 ? (pv < 0 && fv < 0) : (pv < 0 && pmt < 0 && fv < 0) + if (allPositive || allNegative) { + const which = solveFor === "RATE" ? "rate" : "number of periods" + setSignError( + `These values can't be solved. To find the ${which}, money paid out and money received need opposite signs — for example, enter what you invest as negative and what you receive as positive.` + ) + setResult(null) + return + } + } let calculatedValue: number switch (solveFor) { @@ -264,10 +283,24 @@ export function TVMCalculator() { case "NPER": { if (ratePerPeriod === 0) { if (pmt === 0 && payment === "0") throw new Error("Payment cannot be 0 when rate is 0") - calculatedValue = pmt === 0 ? 0 : -(pv + fv) / pmt + const calculatedNper = pmt === 0 ? 0 : -(pv + fv) / pmt + if (!isFinite(calculatedNper) || calculatedNper <= 0) { + throw new Error("These payments are too small to reach this future value. Try a larger payment, a higher rate, or a lower target.") + } + calculatedValue = calculatedNper } else { const pmtAdj = pmt * timingMultiplier - calculatedValue = Math.log((pmtAdj - fv * ratePerPeriod) / (pmtAdj + pv * ratePerPeriod)) / Math.log(1 + ratePerPeriod) + const numerator = pmtAdj - fv * ratePerPeriod + const denominator = pmtAdj + pv * ratePerPeriod + const ratio = numerator / denominator + if (ratio <= 0) { + throw new Error("These payments are too small to reach this future value. Try a larger payment, a higher rate, or a lower target.") + } + const calculatedNper = Math.log(ratio) / Math.log(1 + ratePerPeriod) + if (!isFinite(calculatedNper) || calculatedNper <= 0) { + throw new Error("These payments are too small to reach this future value. Try a larger payment, a higher rate, or a lower target.") + } + calculatedValue = calculatedNper } break } @@ -433,7 +466,8 @@ export function TVMCalculator() { fv: "30000", rate: "3.5", compoundFreq: "12", - paymentFreq: "12", + paymentFreqMode: "different" as PaymentFrequencyMode, + paymentFreq: "26", }, }; } @@ -518,7 +552,7 @@ export function TVMCalculator() { "Number of Periods Example: Solve for time to pay off credit card", bullets: [ "Present value (credit card balance): positive", - "Payment per period (monthly payments): negative", + "Payment per period (bi-weekly payments): negative", "Future value (remaining balance): zero", ], example: { @@ -528,7 +562,7 @@ export function TVMCalculator() { fv: "0", rate: "18", compoundFreq: "365", - paymentFreq: "12", + paymentFreq: "26", paymentFreqMode: "different" as PaymentFrequencyMode, }, }; @@ -551,24 +585,17 @@ export function TVMCalculator() { {showHowToUse && (
-

Cash Flow Signs

+

Cash Flow Signs

This calculator uses signs to show the direction of money:

-
    -
  • Positive (+): cash inflow (money you receive)
  • -
  • Negative (−): cash outflow (money you pay)
  • -
- - {showExamples && ( -
-
- I am: - {(["saving", "borrowing"] as const).map(mode => ( +
+
Positive — money you receive (cash in)
+
Negative — money you pay (cash out)
+
+
+
+ Example — I am: +
+ {(["saving", "borrowing"] as const).map((mode, index) => ( ))}
- {currentExample && ( -
-

{currentExample.title}

+
+ {currentExample && ( +
+

{currentExample.title}

    {currentExample.bullets.map((bullet, idx) =>
  • {bullet}
  • )}
)}
- )} +
-
)}
) @@ -618,6 +645,7 @@ export function TVMCalculator() { {/* Solve-for tabs */}
+

Solve for

{SOLVE_OPTIONS.map((option) => (
)} @@ -673,7 +702,7 @@ export function TVMCalculator() { onBlur={(e) => handleInputBlur(e.target.value, setPayment)} className={`border-border pl-7 bg-card ${getFieldError("payment") ? "border-destructive" : ""}`} />
- {getFieldError("payment") &&

{getFieldError("payment")}

} + {getFieldError("payment") &&

{getFieldError("payment")}

}
)} @@ -688,7 +717,7 @@ export function TVMCalculator() { onBlur={(e) => handleInputBlur(e.target.value, setFutureValue)} className={`border-border pl-7 bg-card ${getFieldError("futureValue") ? "border-destructive" : ""}`} />
- {getFieldError("futureValue") &&

{getFieldError("futureValue")}

} + {getFieldError("futureValue") &&

{getFieldError("futureValue")}

}
)} @@ -703,7 +732,7 @@ export function TVMCalculator() { onBlur={(e) => handleInputBlur(e.target.value, setPayment)} className={`border-border pl-7 bg-card ${getFieldError("payment") ? "border-destructive" : ""}`} />
- {getFieldError("payment") &&

{getFieldError("payment")}

} + {getFieldError("payment") &&

{getFieldError("payment")}

}
)} @@ -718,7 +747,7 @@ export function TVMCalculator() { className={`border-border pr-8 bg-card ${getFieldError("annualRate") ? "border-destructive" : ""}`} /> %
- {getFieldError("annualRate") &&

{getFieldError("annualRate")}

} + {getFieldError("annualRate") &&

{getFieldError("annualRate")}

}
)} @@ -729,7 +758,7 @@ export function TVMCalculator() { { const val = e.target.value; if (val === "" || /^\d*$/.test(val)) setPeriods(val) }} className={`border-border bg-card ${getFieldError("periods") ? "border-destructive" : ""}`} /> - {getFieldError("periods") &&

{getFieldError("periods")}

} + {getFieldError("periods") &&

{getFieldError("periods")}

}
)} @@ -783,7 +812,7 @@ export function TVMCalculator() {
{(["end", "beginning"] as const).map((timing, i) => (
- +
{/* Results Card */} - +

{currentOption?.label}

- {calcError ? ( -

{calcError}

+ {displayError ? ( +

{displayError}

) : result !== null ? (

{formatResult(result)}

) : ( @@ -819,18 +848,16 @@ export function TVMCalculator() {
{/* Mobile sticky footer */} -
-
-
-
{currentOption?.label}
- {calcError ? ( -

{calcError}

+
+
+
{currentOption?.label}
+ {displayError ? ( +

{displayError}

) : result !== null ? (

{formatResult(result)}

) : ( -

Enter values above

+

Enter values above

)} -
@@ -839,4 +866,4 @@ export function TVMCalculator() { ) } -export default TVMCalculator \ No newline at end of file +export default TVMCalculator diff --git a/app/page.tsx b/app/page.tsx index 6cfe203..845d3ad 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,8 +2,7 @@ import fs from 'fs'; import path from 'path'; import Link from 'next/link'; -import React from 'react'; -import "@/app/ui/globals.css"; +import React from 'react'; // Function to get all page routes from the app directory function getPageRoutes() { @@ -48,52 +47,46 @@ export default function HomePage() { const routes = getPageRoutes(); return ( -
-
-

- Welcome to The IFDM Lessons and Games -

-
-

- This page helps developers find the pages they are working on quicker. -

-
+
+
+

+ Welcome to The IFDM Lessons and Games +

-
-

- Available Pages -

+
+

+ Available Pages +

- {routes.length > 0 ? ( -
- {routes.map((route) => ( - -

- {route.name} -

-

- {route.path} -

- - ))} -
- ) : ( -

- No pages found. Create some pages in the app directory! -

- )} -
+ {routes.length > 0 ? ( +
+ {routes.map((route) => ( + +

+ {route.name} +

+

{route.path}

+ + ))} +
+ ) : ( +

+ No pages found. Create some pages in the app directory! +

+ )} +
-
-

- This page automatically updates when you add new pages to your app directory. -

-
-
+
+

+ This page automatically updates when you add new pages to your app + directory. +

+
+
); } diff --git a/docs/deployment.md b/docs/deployment.md index caa38ff..a130a2f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,43 +1,110 @@ -# Instructions for deployment and embedding +# Instructions for deployment and embedding -## Deployment +## Environments -The app deploys to https://ifdm-learning.stanford.edu/ via GitHub Pages and the `gh-pages` branch. The deploy script will automatically run the build step and generate the static files based on your local `1.x` branch code. Make sure you are on the latest `1.x` branch before deploying. +| Environment | Branch | URL | Command | +|---|---|---|---| +| Production | `1.x` | https://ifdm-learning.stanford.edu/ | `yarn deploy` | +| Staging | `dev` | https://su-sws.github.io/ifdm_learning_apps_staging/ | `yarn deploy:staging` | -### Deployment Steps +--- + +## Development workflow -1. Ensure you are on the latest `1.x` branch code: - - `git checkout 1.x && git fetch && git pull` -2. Make sure you are using the correct Node version: - - If you use nvm and are not already on the correct version, run: `nvm use` -3. (Optional) Test the build locally before deploying: - - `yarn build` -4. Deploy to GitHub Pages: - - `yarn deploy` -5. After deploying, verify the deployment: - - Visit https://github.com/SU-SWS/ifdm_learning_apps/deployments to confirm the deployment ran successfully. +Feature branches are developed and reviewed via pull request, then merged in two stages: + +``` +feature/my-branch → PR → dev → yarn deploy:staging → QA on staging + ↓ approved + PR to 1.x → yarn deploy → production +``` + +1. Open a PR from your feature branch targeting `dev` +2. After merge, deploy to staging and verify: `yarn deploy:staging` +3. Once staging is confirmed, open a PR from `dev` into `1.x` +4. After merge, deploy to production: `yarn deploy` --- -### Embedding a Calculator +## Deploying to production + +The deploy script enforces that you are on the `1.x` branch before building. + +1. Switch to the latest `1.x`: + ``` + git checkout 1.x && git fetch && git pull + ``` +2. Confirm the correct Node version is active: + ``` + nvm use + ``` +3. Deploy: + ``` + yarn deploy + ``` +4. Verify at https://github.com/SU-SWS/ifdm_learning_apps/deployments + +--- + +## Deploying to staging + +The staging deploy script enforces that you are on the `dev` branch before building. + +1. Switch to the latest `dev`: + ``` + git checkout dev && git fetch && git pull + ``` +2. Confirm the correct Node version is active: + ``` + nvm use + ``` +3. Deploy: + ``` + yarn deploy:staging + ``` +4. Verify at https://github.com/SU-SWS/ifdm_learning_apps_staging/deployments and preview at https://su-sws.github.io/ifdm_learning_apps_staging/ + +--- + +## One-time staging setup (already done — for reference) + +If the staging environment ever needs to be rebuilt from scratch: + +1. Create a new GitHub repo: `SU-SWS/ifdm_learning_apps_staging` +2. In that repo's Settings → Pages, set the source to the `gh-pages` branch +3. Add the staging remote to your local clone: + ``` + git remote add staging git@github.com:SU-SWS/ifdm_learning_apps_staging.git + ``` + Note: `yarn deploy:staging` targets this remote directly via `gh-pages -r`; the local remote entry is for reference and manual pushes only. + +--- + +## Embedding a calculator Example iFrame: -```angular2html +```html ``` + See below screencast as an example of embedding the iframe: ![Embedding Example](images/embedding-in-mighty.gif) +--- + ## Troubleshooting -### Changes Not Appearing +### Changes not appearing 1. Clear browser cache -2. Check GitHub Pages deployment status -3. Verify the gh-pages branch updated +2. Check GitHub Pages deployment status in the repo's Deployments tab +3. Verify the `gh-pages` branch updated with a recent commit + +### Staging assets not loading (404 on `/_next/` paths) +The staging build sets `basePath=/ifdm_learning_apps_staging` so all asset paths are prefixed correctly for the project URL. If you see 404s on static assets, confirm `build:staging` was used (not the plain `build` command). -### iframe Issues +### iframe issues - Ensure the source URL is correct - Check for CORS restrictions - Test the iframe URL directly in browser diff --git a/next.config.ts b/next.config.ts index 36a15be..520da8c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,7 +3,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: 'export', trailingSlash: true, - basePath: '', + basePath: process.env.NEXT_BASE_PATH || '', eslint: { // Directories to run ESLint on during builds dirs: ['app'], diff --git a/package.json b/package.json index aabc014..5edc097 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "dev": "next dev --turbopack", "build:local": "next build", "build": "echo \" \" > public/.nojekyll && next build && cp CNAME out/CNAME", - "predeploy": "node scripts/check-branch.js && yarn build", - "deploy": "gh-pages -d out", + "build:staging": "echo \" \" > public/.nojekyll && NEXT_BASE_PATH=/ifdm_learning_apps_staging next build", + "predeploy": "node scripts/check-branch.js 1.x && yarn build", + "deploy": "gh-pages -d out --dotfiles", + "deploy:staging": "node scripts/check-branch.js dev && yarn build:staging && gh-pages -d out --dotfiles -r git@github.com:SU-SWS/ifdm_learning_apps_staging.git", "start": "next start", "lint": "next lint" }, diff --git a/scripts/check-branch.js b/scripts/check-branch.js index 9e40988..7f87ec1 100644 --- a/scripts/check-branch.js +++ b/scripts/check-branch.js @@ -1,11 +1,12 @@ const { execSync } = require('child_process'); +const allowedBranch = process.argv[2] || '1.x'; + try { const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim(); - if (branch !== '1.x') { - console.error(`Build blocked on all branches but 1.x`); - console.error('Builds are not allowed on this branch.'); + if (branch !== allowedBranch) { + console.error(`Deploy blocked: must be on branch '${allowedBranch}' (currently on '${branch}').`); process.exit(1); } } catch (error) {