diff --git a/index.html b/index.html index 41bdb58..9e4acec 100644 --- a/index.html +++ b/index.html @@ -7,21 +7,21 @@ - GreenLight — Financial Planning Tool - - + GreenLight — Personal Finance Calculator + + - - + + - + @@ -31,7 +31,7 @@ "@context": "https://schema.org", "@type": "SoftwareApplication", "name": "GreenLight", - "description": "Privacy-first financial planning tool that runs entirely in your browser. No account required.", + "description": "Privacy-first personal finance calculator that runs entirely in your browser. No account required.", "applicationCategory": "FinanceApplication", "operatingSystem": "Web", "url": "https://gotthegreenlight.com/", diff --git a/src/components/ConformingStatus.jsx b/src/components/ConformingStatus.jsx index 4efd22a..8240592 100644 --- a/src/components/ConformingStatus.jsx +++ b/src/components/ConformingStatus.jsx @@ -1,28 +1,28 @@ import { useMemo } from "react"; import { colors, styles } from "../theme.js"; import { fmt } from "../lib/calculations.js"; -import { detectJumbo, suggestConformingDown, calcJumboImpact, calcEffectiveRate } from "../lib/loanLimits.js"; +import { detectJumbo, calcConformingDown, calcJumboImpact, calcEffectiveRate } from "../lib/loanLimits.js"; import { YEAR } from "../data/conformingLimits.js"; /** * Conforming Loan Status card — shows whether the loan is conforming or jumbo, - * with rate impact analysis and a suggestion to increase down payment. + * with rate impact analysis and a conforming down-payment option. */ export default function ConformingStatus({ zipCode, loanAmount, homePrice, currentDownPercent, baseRate, termYears, jumboSpread, zipInfo, - onZipChange, onSpreadChange, onApplySuggestion, + onZipChange, onSpreadChange, onApplyConformingDown, }) { - const hasZip = zipCode && zipCode.length === 5; + const hasZip = zipCode?.length === 5; const jumbo = useMemo( () => hasZip ? detectJumbo(loanAmount, zipCode, !!zipInfo) : null, [hasZip, loanAmount, zipCode, zipInfo], ); - const suggestion = useMemo( - () => jumbo?.isJumbo ? suggestConformingDown(homePrice, jumbo.conformingLimit, currentDownPercent) : null, + const conformingOption = useMemo( + () => jumbo?.isJumbo ? calcConformingDown(homePrice, jumbo.conformingLimit, currentDownPercent) : null, [jumbo, homePrice, currentDownPercent], ); @@ -35,9 +35,9 @@ export default function ConformingStatus({ ? calcEffectiveRate(baseRate, true, jumboSpread) : baseRate; - const locationLabel = zipInfo - ? `${zipInfo.city}, ${zipInfo.county} Co., ${zipInfo.state}` - : hasZip ? "Looking up..." : null; + let locationLabel = null; + if (zipInfo) locationLabel = `${zipInfo.city}, ${zipInfo.county} Co., ${zipInfo.state}`; + else if (hasZip) locationLabel = "Looking up..."; return (
@@ -124,7 +124,7 @@ export default function ConformingStatus({ onSpreadChange(parseFloat(e.target.value) || 0)} + onChange={e => onSpreadChange(Number.parseFloat(e.target.value) || 0)} style={{ ...styles.input, width: 80, padding: "4px 8px", fontSize: 13 }} />
% above conforming
@@ -151,25 +151,25 @@ export default function ConformingStatus({ )}
- {/* Suggestion to increase down payment */} - {suggestion && ( + {/* Conforming option — down payment to avoid jumbo */} + {conformingOption && (
- Increase down payment to{" "} + A down payment of{" "} - {suggestion.requiredDownPercent.toFixed(1)}% + {conformingOption.requiredDownPercent.toFixed(1)}% - {" "}({fmt(suggestion.requiredDownAmount)}) to stay conforming. + {" "}({fmt(conformingOption.requiredDownAmount)}) would keep the loan conforming. - +{fmt(suggestion.additionalDown)} more needed + +{fmt(conformingOption.additionalDown)} more needed
diff --git a/src/lib/__tests__/loanLimits.test.js b/src/lib/__tests__/loanLimits.test.js index 80ac4d7..cd6f0fb 100644 --- a/src/lib/__tests__/loanLimits.test.js +++ b/src/lib/__tests__/loanLimits.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { getConformingLimit, BASELINE_LIMIT } from "../../data/conformingLimits.js"; import { - detectJumbo, calcEffectiveRate, suggestConformingDown, calcJumboImpact, + detectJumbo, calcEffectiveRate, calcConformingDown, calcJumboImpact, DEFAULT_JUMBO_PREMIUM, } from "../loanLimits.js"; @@ -104,21 +104,21 @@ describe("calcEffectiveRate", () => { }); }); -// ── suggestConformingDown ──────────────────────────────────────────────────── +// ── calcConformingDown ──────────────────────────────────────────────────── -describe("suggestConformingDown", () => { +describe("calcConformingDown", () => { it("returns null when loan is already conforming", () => { // 500K home, 20% down = 400K loan, well under 832,750 - expect(suggestConformingDown(500000, BASELINE_LIMIT, 20)).toBeNull(); + expect(calcConformingDown(500000, BASELINE_LIMIT, 20)).toBeNull(); }); it("returns null when home price is below limit", () => { - expect(suggestConformingDown(700000, BASELINE_LIMIT, 0)).toBeNull(); + expect(calcConformingDown(700000, BASELINE_LIMIT, 0)).toBeNull(); }); - it("suggests increased down payment for jumbo loan", () => { + it("calculates required down payment for jumbo loan", () => { // 1M home, 832,750 limit, 10% down = 900K loan (67,250 over limit) - const result = suggestConformingDown(1000000, BASELINE_LIMIT, 10); + const result = calcConformingDown(1000000, BASELINE_LIMIT, 10); expect(result).not.toBeNull(); // Required down = 1M - 832,750 = 167,250 = 16.725% → rounds up to 16.8% expect(result.requiredDownPercent).toBeCloseTo(16.8, 0); @@ -127,8 +127,8 @@ describe("suggestConformingDown", () => { }); it("returns null for zero/invalid inputs", () => { - expect(suggestConformingDown(0, BASELINE_LIMIT, 20)).toBeNull(); - expect(suggestConformingDown(-100, BASELINE_LIMIT, 20)).toBeNull(); + expect(calcConformingDown(0, BASELINE_LIMIT, 20)).toBeNull(); + expect(calcConformingDown(-100, BASELINE_LIMIT, 20)).toBeNull(); }); }); diff --git a/src/lib/__tests__/purchasePlanner.test.js b/src/lib/__tests__/purchasePlanner.test.js index 8d2f914..2bce6fe 100644 --- a/src/lib/__tests__/purchasePlanner.test.js +++ b/src/lib/__tests__/purchasePlanner.test.js @@ -148,20 +148,20 @@ describe("calcLiquidationAnalysis", () => { cashFlow: { net: cfNet }, }); - it("returns canAfford=true when surplus", () => { + it("returns isCovered=true when surplus", () => { const summary = makeSummary(20000, 80000, 5000, 10000, 5000); // Available: 20k + (80k - 5k) + 10k + 5k = 110k const result = calcLiquidationAnalysis(90000, summary); - expect(result.canAfford).toBe(true); + expect(result.isCovered).toBe(true); expect(result.surplus).toBe(20000); expect(result.shortfall).toBe(0); }); - it("returns canAfford=false when shortfall", () => { + it("returns isCovered=false when shortfall", () => { const summary = makeSummary(5000, 20000, 3000, 0, 2000); // Available: 5k + (20k - 3k) + 0 + 2k = 24k const result = calcLiquidationAnalysis(50000, summary); - expect(result.canAfford).toBe(false); + expect(result.isCovered).toBe(false); expect(result.shortfall).toBe(26000); expect(result.surplus).toBe(0); }); @@ -177,7 +177,7 @@ describe("calcLiquidationAnalysis", () => { it("handles zero values gracefully", () => { const summary = makeSummary(0, 0, 0, 0, 0); const result = calcLiquidationAnalysis(10000, summary); - expect(result.canAfford).toBe(false); + expect(result.isCovered).toBe(false); expect(result.totalAvailable).toBe(0); expect(result.shortfall).toBe(10000); }); @@ -186,7 +186,7 @@ describe("calcLiquidationAnalysis", () => { // Simulate summary with missing nested properties const sparse = { cashTotal: 5000 }; const result = calcLiquidationAnalysis(10000, sparse); - expect(result.canAfford).toBe(false); + expect(result.isCovered).toBe(false); expect(result.totalAvailable).toBe(5000); expect(Number.isNaN(result.totalAvailable)).toBe(false); expect(Number.isNaN(result.shortfall)).toBe(false); diff --git a/src/lib/loanLimits.js b/src/lib/loanLimits.js index 079bcd0..544e136 100644 --- a/src/lib/loanLimits.js +++ b/src/lib/loanLimits.js @@ -5,10 +5,9 @@ * conforming loan limit) and calculates the rate/payment impact. */ -import { getConformingLimit, BASELINE_LIMIT } from "../data/conformingLimits.js"; +import { getConformingLimit } from "../data/conformingLimits.js"; +export { BASELINE_LIMIT } from "../data/conformingLimits.js"; import { calcMonthlyPI } from "./mortgageCalc.js"; - -export { BASELINE_LIMIT }; export const DEFAULT_JUMBO_PREMIUM = 0.25; // percentage points above conforming rate /** @@ -43,14 +42,14 @@ export function calcEffectiveRate(baseRatePercent, isJumbo, jumboSpreadPercent = } /** - * Suggest a down payment increase to stay under the conforming limit. + * Calculate the down payment needed to stay under the conforming limit. * Returns null if the loan is already conforming. * @param {number} homePrice * @param {number} conformingLimit * @param {number} currentDownPercent * @returns {{ requiredDownPercent: number, requiredDownAmount: number, additionalDown: number } | null} */ -export function suggestConformingDown(homePrice, conformingLimit, currentDownPercent) { +export function calcConformingDown(homePrice, conformingLimit, currentDownPercent) { if (!homePrice || homePrice <= 0 || !conformingLimit) return null; const currentDown = homePrice * (currentDownPercent / 100); diff --git a/src/lib/mortgageCalc.js b/src/lib/mortgageCalc.js index 1dacf8a..96032c0 100644 --- a/src/lib/mortgageCalc.js +++ b/src/lib/mortgageCalc.js @@ -144,18 +144,18 @@ export function calcPointsBreakEven(pointsCost, monthlySavings, opportunityRateP } /** - * Buy-down recommendation signal based on break-even vs expected stay. + * Buy-down break-even signal based on break-even vs expected stay. * green: adjusted break-even <= 60% of stay (clear win) * yellow: 60-100% of stay (marginal) - * red: > stay or Infinity (don't buy down) + * red: > stay or Infinity (unlikely to break even) */ export function calcBuyDownSignal(adjustedBreakEvenMonths, expectedStayYears) { const stayMonths = expectedStayYears * 12; if (adjustedBreakEvenMonths === Infinity || adjustedBreakEvenMonths > stayMonths) { - return { signal: "red", label: "Skip the buy-down, invest instead" }; + return { signal: "red", label: "Buy-down unlikely to break even" }; } if (adjustedBreakEvenMonths <= stayMonths * 0.6) { - return { signal: "green", label: "Buy down the rate" }; + return { signal: "green", label: "Buy-down likely to break even" }; } return { signal: "yellow", label: "Marginal \u2014 depends on priorities" }; } diff --git a/src/lib/purchasePlanner.js b/src/lib/purchasePlanner.js index 61a2a8d..5488b88 100644 --- a/src/lib/purchasePlanner.js +++ b/src/lib/purchasePlanner.js @@ -101,12 +101,12 @@ function fmtShortfall(n) { // ─── Public API ─────────────────────────────────────────────────────────────── /** - * Liquidation analysis — can the user afford the purchase with current assets? + * Liquidation analysis — do available funds cover the purchase at current asset values? * Uses calcSummary output to determine available cash from all sources. * * @param {number} cashNeeded - total cash needed from calcTotalCashNeeded * @param {Object} summary - result of calcSummary(state, prices) - * @returns {{ canAfford, surplus, shortfall, cashContribution, assetContribution, retirementContribution, cashFlowContribution, totalAvailable }} + * @returns {{ isCovered, surplus, shortfall, cashContribution, assetContribution, retirementContribution, cashFlowContribution, totalAvailable }} */ export function calcLiquidationAnalysis(cashNeeded, summary) { const cashAvailable = summary.cashTotal || 0; @@ -120,7 +120,7 @@ export function calcLiquidationAnalysis(cashNeeded, summary) { const surplus = totalAvailable - cashNeeded; return { - canAfford: surplus >= 0, + isCovered: surplus >= 0, surplus: Math.max(0, surplus), shortfall: Math.max(0, -surplus), cashContribution: cashAvailable, diff --git a/src/lib/readiness.js b/src/lib/readiness.js index af7b8a3..4df00f4 100644 --- a/src/lib/readiness.js +++ b/src/lib/readiness.js @@ -1,6 +1,6 @@ -// Financial readiness projection engine -// Projects cash position forward month-by-month to determine when the user -// can afford a target purchase amount. Default: 0% growth (pure linear). +// Purchase readiness projection engine +// Projects cash position forward month-by-month to determine when available +// funds meet a target purchase amount. Default: 0% growth (pure linear). import { calcSummary, calcMonthlySavings, isLongTerm } from "./calculations.js"; diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 5037b3f..f1f1f80 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -332,10 +332,10 @@ function ReadinessWidget({ state, projections, statusResult, cashNeeded, monthly
{label}
{isReady - ? "Ready now" + ? "Funds available" : readinessDate - ? `Ready in ~${readinessDate.month} months` - : "Not reachable in 5 years"} + ? `Estimated in ~${readinessDate.month} months` + : "Not estimated within 5 years"}
diff --git a/src/pages/LoansCalc.jsx b/src/pages/LoansCalc.jsx index 9656df6..607494e 100644 --- a/src/pages/LoansCalc.jsx +++ b/src/pages/LoansCalc.jsx @@ -179,7 +179,7 @@ function MortgageView({
Term (years)
updateMortgage("ratePercent", parseFloat(e.target.value) || 0)} + onChange={e => updateMortgage("ratePercent", Number.parseFloat(e.target.value) || 0)} style={styles.input} /> {latestFredRate && ( @@ -207,7 +207,7 @@ function MortgageView({ updateMortgage("pmiRate", parseFloat(e.target.value) || 0)} + onChange={e => updateMortgage("pmiRate", Number.parseFloat(e.target.value) || 0)} style={styles.input} /> @@ -219,7 +219,7 @@ function MortgageView({ updateMortgage("propertyTax", parseFloat(e.target.value) || 0)} + onChange={e => updateMortgage("propertyTax", Number.parseFloat(e.target.value) || 0)} style={styles.input} /> @@ -228,7 +228,7 @@ function MortgageView({ updateMortgage("homeInsurance", parseFloat(e.target.value) || 0)} + onChange={e => updateMortgage("homeInsurance", Number.parseFloat(e.target.value) || 0)} style={styles.input} /> @@ -237,7 +237,7 @@ function MortgageView({ updateMortgage("hoaDues", parseFloat(e.target.value) || 0)} + onChange={e => updateMortgage("hoaDues", Number.parseFloat(e.target.value) || 0)} style={styles.input} /> @@ -246,7 +246,7 @@ function MortgageView({ updateMortgage("expectedStayYears", parseInt(e.target.value) || 10)} + onChange={e => updateMortgage("expectedStayYears", Number.parseInt(e.target.value) || 10)} style={styles.input} /> @@ -265,7 +265,7 @@ function MortgageView({ zipInfo={zipInfo} onZipChange={val => updatePurchase("zipCode", val)} onSpreadChange={val => updateMortgage("jumboSpreadPercent", val)} - onApplySuggestion={pct => updatePurchase("downPaymentPercent", pct)} + onApplyConformingDown={pct => updatePurchase("downPaymentPercent", pct)} /> {/* Rate Chart */} @@ -377,7 +377,7 @@ function MortgageView({ updateMortgage("pointsBought", parseFloat(e.target.value) || 0)} + onChange={e => updateMortgage("pointsBought", Number.parseFloat(e.target.value) || 0)} style={styles.input} /> @@ -386,7 +386,7 @@ function MortgageView({ updateMortgage("opportunityCostRate", parseFloat(e.target.value) || 0)} + onChange={e => updateMortgage("opportunityCostRate", Number.parseFloat(e.target.value) || 0)} style={styles.input} />
S&P 500 avg: ~7%
@@ -512,7 +512,7 @@ function AutoLoanView({ purchase, autoLoan, updateAutoLoan, onOverride, onToggle
Term (months)
updateAutoLoan("ratePercent", parseFloat(e.target.value) || 0)} + onChange={e => updateAutoLoan("ratePercent", Number.parseFloat(e.target.value) || 0)} style={styles.input} /> @@ -537,7 +537,7 @@ function AutoLoanView({ purchase, autoLoan, updateAutoLoan, onOverride, onToggle updateAutoLoan("tradeInValue", parseFloat(e.target.value) || 0)} + onChange={e => updateAutoLoan("tradeInValue", Number.parseFloat(e.target.value) || 0)} style={styles.input} /> diff --git a/src/pages/PurchasePlanning.jsx b/src/pages/PurchasePlanning.jsx index 1de4e88..b6b8595 100644 --- a/src/pages/PurchasePlanning.jsx +++ b/src/pages/PurchasePlanning.jsx @@ -75,7 +75,7 @@ export default function PurchasePlanning({ state, updateState, prices }) { ); } - const affordColor = liquidation.canAfford + const statusColor = liquidation.isCovered ? (liquidation.surplus > cashNeeded.total * 0.1 ? colors.green : colors.amber) : colors.red; @@ -138,7 +138,7 @@ export default function PurchasePlanning({ state, updateState, prices }) { updatePurchase(isHome ? "homePrice" : "carPrice", parseFloat(e.target.value) || 0)} + onChange={e => updatePurchase(isHome ? "homePrice" : "carPrice", Number.parseFloat(e.target.value) || 0)} style={styles.input} /> @@ -149,7 +149,7 @@ export default function PurchasePlanning({ state, updateState, prices }) { updatePurchase("downPaymentPercent", parseFloat(e.target.value) || 0)} + onChange={e => updatePurchase("downPaymentPercent", Number.parseFloat(e.target.value) || 0)} style={styles.input} /> @@ -166,7 +166,7 @@ export default function PurchasePlanning({ state, updateState, prices }) { updatePurchase("carDownPayment", parseFloat(e.target.value) || 0)} + onChange={e => updatePurchase("carDownPayment", Number.parseFloat(e.target.value) || 0)} style={styles.input} /> @@ -253,7 +253,7 @@ export default function PurchasePlanning({ state, updateState, prices }) { borderRadius: 6, padding: 12, marginBottom: 12, fontSize: 13, color: colors.text, }}> Loan exceeds the {fmt(jumbo.conformingLimit)} conforming limit by {fmt(jumbo.overage)}. - Consider increasing the down payment on the Loans page. + A higher down payment on the Loans page would bring the loan under the conforming limit. ); })()} @@ -299,10 +299,10 @@ export default function PurchasePlanning({ state, updateState, prices }) { - {/* Liquidation Analysis — Can You Afford It? */} + {/* Liquidation Analysis — Purchase vs. Available Funds */}
- CAN YOU AFFORD IT? + PURCHASE VS. AVAILABLE FUNDS
@@ -333,16 +333,16 @@ export default function PurchasePlanning({ state, updateState, prices }) {
{fmt(liquidation.totalAvailable)}
-
{liquidation.canAfford ? "Surplus" : "Shortfall"}
-
- {liquidation.canAfford ? fmt(liquidation.surplus) : `(${fmt(liquidation.shortfall).replace("$", "")})`} +
{liquidation.isCovered ? "Surplus" : "Shortfall"}
+
+ {liquidation.isCovered ? fmt(liquidation.surplus) : `(${fmt(liquidation.shortfall).replace("$", "")})`}
- {!liquidation.canAfford && ( + {!liquidation.isCovered && (
- You need {fmt(liquidation.shortfall)} more to cover this purchase at current asset values. + There is a {fmt(liquidation.shortfall)} gap at current asset values.
)}
diff --git a/src/pages/Readiness.jsx b/src/pages/Readiness.jsx index 2aea751..e76a7fd 100644 --- a/src/pages/Readiness.jsx +++ b/src/pages/Readiness.jsx @@ -108,7 +108,7 @@ export default function Readiness({ state, updateState, prices }) { if (!purchase.category) { return (
-
Financial Readiness
+
Purchase Readiness
Activate purchase planning from the Dashboard to use readiness projections.
); @@ -116,7 +116,7 @@ export default function Readiness({ state, updateState, prices }) { return (
-

Financial Readiness

+

Purchase Readiness

{/* Hero: You need / You have / Gap */}
{isReady - ? "Ready now" + ? "Funds available" : readinessDate - ? `Ready by ${readinessDate.date}` - : "Not reachable in 5 years"} + ? `Estimated by ${readinessDate.date}` + : "Not estimated within 5 years"}
{targetPurchaseDate && !isReady && readinessDate && targetMonth != null && (
@@ -326,7 +326,7 @@ export default function Readiness({ state, updateState, prices }) { {/* Footer */}
- Projections use current prices and tax rates · Growth assumptions are opt-in + Estimates use current prices and tax rates · For illustration only · Growth assumptions are opt-in
); diff --git a/src/pages/SetupWizard.jsx b/src/pages/SetupWizard.jsx index 71cae36..1ac3a57 100644 --- a/src/pages/SetupWizard.jsx +++ b/src/pages/SetupWizard.jsx @@ -661,7 +661,7 @@ function WelcomeStep() { marginBottom: 14, }} />
- Free, open-source financial planning. + Free, open-source personal finance calculator.
All your data lives in your browser — nothing is sent to a server. No account needed.