Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<!-- Primary SEO -->
<title>GreenLight — Financial Planning Tool</title>
<meta name="description" content="GreenLight — Free, privacy-first financial planning tool. See your complete financial picture, plan purchases, compare mortgages, and understand your tax situation. No account needed, runs entirely in your browser." />
<meta name="keywords" content="financial planning, mortgage calculator, purchase planning, tax calculator, asset management, net worth, capital gains, liquidation planner" />
<title>GreenLight — Personal Finance Calculator</title>
<meta name="description" content="GreenLight — Free, privacy-first personal finance calculator. See your complete financial picture, plan purchases, compare mortgages, and understand your tax situation. No account needed, runs entirely in your browser." />
<meta name="keywords" content="personal finance, mortgage calculator, purchase planning, tax calculator, asset tracking, net worth, capital gains, purchase readiness" />
<link rel="canonical" href="https://gotthegreenlight.com/" />

<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://gotthegreenlight.com/" />
<meta property="og:title" content="GreenLight — Financial Planning Tool" />
<meta property="og:description" content="Know exactly when all the math turns green. Free, private, browser-only financial planning." />
<meta property="og:title" content="GreenLight — Personal Finance Calculator" />
<meta property="og:description" content="Know exactly when all the math turns green. Free, private, browser-only financial calculator." />
<meta property="og:image" content="https://gotthegreenlight.com/og-image.png" />

<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="GreenLight — Financial Planning Tool" />
<meta name="twitter:title" content="GreenLight — Personal Finance Calculator" />
<meta name="twitter:description" content="Know exactly when all the math turns green." />
<meta name="twitter:image" content="https://gotthegreenlight.com/og-image.png" />

Expand All @@ -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/",
Expand Down
34 changes: 17 additions & 17 deletions src/components/ConformingStatus.jsx
Original file line number Diff line number Diff line change
@@ -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],
);

Expand All @@ -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 (
<div style={{ ...styles.card, marginBottom: 14 }}>
Expand Down Expand Up @@ -124,7 +124,7 @@ export default function ConformingStatus({
<input
type="number" step="0.125" min="0" max="2"
value={jumboSpread}
onChange={e => 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 }}
/>
<div style={{ fontSize: 10, color: colors.dim, marginTop: 1 }}>% above conforming</div>
Expand All @@ -151,25 +151,25 @@ export default function ConformingStatus({
)}
</div>

{/* Suggestion to increase down payment */}
{suggestion && (
{/* Conforming option — down payment to avoid jumbo */}
{conformingOption && (
<div style={{
background: colors.card, border: `1px solid ${colors.border}`,
borderRadius: 6, padding: 12, marginTop: 8,
display: "flex", justifyContent: "space-between", alignItems: "center",
}}>
<div style={{ fontSize: 13, color: colors.text }}>
Increase down payment to{" "}
A down payment of{" "}
<span style={{ color: colors.green, fontWeight: 600 }}>
{suggestion.requiredDownPercent.toFixed(1)}%
{conformingOption.requiredDownPercent.toFixed(1)}%
</span>
{" "}({fmt(suggestion.requiredDownAmount)}) to stay conforming.
{" "}({fmt(conformingOption.requiredDownAmount)}) would keep the loan conforming.
<span style={{ color: colors.dim, marginLeft: 8 }}>
+{fmt(suggestion.additionalDown)} more needed
+{fmt(conformingOption.additionalDown)} more needed
</span>
</div>
<button
onClick={() => onApplySuggestion(suggestion.requiredDownPercent)}
onClick={() => onApplyConformingDown(conformingOption.requiredDownPercent)}
style={{
...styles.btn, fontSize: 11, padding: "5px 12px",
color: colors.green, borderColor: colors.greenDim,
Expand Down
2 changes: 1 addition & 1 deletion src/components/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export default function Layout({ sellDate, onSellDateChange, purchaseDate, onPur
letterSpacing: 1,
textTransform: "uppercase",
}}>
Financial Planning
Personal Finance
</div>
</div>

Expand Down
18 changes: 9 additions & 9 deletions src/lib/__tests__/loanLimits.test.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
Expand All @@ -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();
});
});

Expand Down
12 changes: 6 additions & 6 deletions src/lib/__tests__/purchasePlanner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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);
Expand Down
9 changes: 4 additions & 5 deletions src/lib/loanLimits.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 4 additions & 4 deletions src/lib/mortgageCalc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
}
Expand Down
6 changes: 3 additions & 3 deletions src/lib/purchasePlanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/lib/readiness.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
6 changes: 3 additions & 3 deletions src/pages/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -332,10 +332,10 @@ function ReadinessWidget({ state, projections, statusResult, cashNeeded, monthly
<div style={{ fontSize: 13, color: colors.dim, textTransform: "uppercase", letterSpacing: 1 }}>{label}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: signalColor, marginTop: 2 }}>
{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"}
</div>
</div>
</div>
Expand Down
Loading