diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 6d2f5ae..1aa04d1 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -1,8 +1,19 @@ +import { useSyncExternalStore } from "react"; import { NavLink, Outlet } from "react-router-dom"; import { colors, fonts } from "../theme.js"; import { track } from "../lib/analytics.js"; import DevImportBanner from "./DevImportBanner.jsx"; +const STALE_MS = 30 * 60 * 1000; // 30 minutes +const TICK_MS = 60_000; + +let _now = Date.now(); +let _listeners = []; +setInterval(() => { _now = Date.now(); _listeners.forEach(fn => fn()); }, TICK_MS); + +function subscribeNow(cb) { _listeners.push(cb); return () => { _listeners = _listeners.filter(fn => fn !== cb); }; } +function getNow() { return _now; } + const CORE_NAV = [ { to: "/", label: "Dashboard", icon: "◈" }, { to: "/assets", label: "Assets", icon: "▤" }, @@ -47,6 +58,9 @@ export default function Layout({ sellDate, onSellDateChange, purchaseDate, onPur : planningMode === "vehicle" ? VEHICLE_PLANNING_NAV : null; + const now = useSyncExternalStore(subscribeNow, getNow); + const isStale = lastFetch && (now - lastFetch.getTime() > STALE_MS); + return (
@@ -104,7 +118,7 @@ export default function Layout({ sellDate, onSellDateChange, purchaseDate, onPur className="gl-kofi" onClick={() => track("kofi_click")} aria-label="Support GreenLight on Ko-fi" - style={{ display: "block", padding: "8px 12px", marginTop: 4, opacity: 0.5, transition: "opacity 0.2s" }}> + style={{ display: "flex", alignItems: "center", padding: "8px 20px 8px 23px", marginTop: 4, opacity: 0.5, transition: "opacity 0.2s" }}> Buy Me a Coffee @@ -116,23 +130,30 @@ export default function Layout({ sellDate, onSellDateChange, purchaseDate, onPur {/* Top bar */}
- {lastFetch && ( - <> - - {lastFetch.toLocaleTimeString()} - - )} - {fetchErr && ⚠ {fetchErr}} + {fetchErr && ⚠ {fetchErr}}
+
+ + onSellDateChange(e.target.value)} + className="gl-date-input" + /> +
{planningMode && (
@@ -144,15 +165,6 @@ export default function Layout({ sellDate, onSellDateChange, purchaseDate, onPur />
)} -
- - onSellDateChange(e.target.value)} - className="gl-date-input" - /> -
diff --git a/src/components/ReadinessTimeline.jsx b/src/components/ReadinessTimeline.jsx index 7bf57f8..5a2c6e4 100644 --- a/src/components/ReadinessTimeline.jsx +++ b/src/components/ReadinessTimeline.jsx @@ -19,8 +19,19 @@ export default function ReadinessTimeline({ readinessMonth, totalMonths, markers const barPct = readinessMonth != null ? pct(readinessMonth) : 100; + // Sort markers by position and stagger labels above/below to avoid overlap + const sorted = [...markers].sort((a, b) => a.month - b.month); + const staggered = []; + for (let i = 0; i < sorted.length; i++) { + let above = i % 2 === 0; + if (i > 0 && Math.abs(pct(sorted[i].month) - pct(sorted[i - 1].month)) < 8) { + above = !staggered[i - 1].above; + } + staggered.push({ ...sorted[i], above }); + } + return ( -
+
{/* Track */}
{/* Filled bar */} @@ -32,7 +43,7 @@ export default function ReadinessTimeline({ readinessMonth, totalMonths, markers }} /> {/* Markers */} - {markers.map((m, i) => { + {staggered.map((m, i) => { const left = pct(m.month); const markerColor = m.type === "target" ? colors.green : m.type === "ltDate" ? colors.blue @@ -42,10 +53,12 @@ export default function ReadinessTimeline({ readinessMonth, totalMonths, markers position: "absolute", top: -4, left: `${left}%`, transform: "translateX(-50%)", width: m.type === "target" ? 3 : 2, height: 36, background: markerColor, opacity: m.type === "target" ? 0.9 : 0.7, - borderStyle: m.type === "target" ? "dashed" : "solid", }}>
diff --git a/src/data/defaults.js b/src/data/defaults.js index cd5777b..bd35ac3 100644 --- a/src/data/defaults.js +++ b/src/data/defaults.js @@ -25,13 +25,17 @@ export const COINGECKO_TICKERS = { pol: "polygon-ecosystem-token", }; -export const DEFAULT_PLATFORMS = { +// Known platform fees — auto-populated when importing from these platforms +export const KNOWN_PLATFORM_FEES = { cs: { name: "ComputerShare", feePerShare: 0.10, flatFee: 10, feePercent: 0 }, gem: { name: "Gemini", feePerShare: 0, flatFee: 0, feePercent: 0.015 }, pp: { name: "Paypal", feePerShare: 0, flatFee: 0, feePercent: 0.02 }, fidelity: { name: "Fidelity", feePerShare: 0, flatFee: 0, feePercent: 0 }, }; +// New users start with no platform fees — populated on import or manually in Settings +export const DEFAULT_PLATFORMS = {}; + export const DEFAULT_TAX_CONFIG = { taxMode: "progressive", // "progressive" (bracket-based) or "flat" (manual rates) taxYear: 2025, @@ -70,6 +74,9 @@ export const DEFAULT_CASH_FLOW = { paycheckAmount: 5395.63, paycheckFrequency: "biweekly", firstPayDate: "2026-03-06", + spousePaycheckAmount: 0, + spousePaycheckFrequency: "biweekly", + spouseFirstPayDate: "", expenses: [ { id: uuid(), name: "Mortgage", amount: 1679, frequency: "monthly", dayOfMonth: 1, startDate: "2026-04-01" }, ], @@ -123,7 +130,7 @@ export const DEFAULT_READINESS = { assetAppreciationRate: 0, }; -export const SCHEMA_VERSION = 3; +export const SCHEMA_VERSION = 6; export function createDefaultState() { return { @@ -200,6 +207,7 @@ export function createSeededState() { { id: uuid(), accountType: "safe_harbor", platform: "Empower", balance: 15000, contributions: 0 }, ], }; + state.platforms = { ...KNOWN_PLATFORM_FEES }; state.priceOverrides = { gme: 24.03, wgme: 4.30 }; return state; } diff --git a/src/lib/__tests__/calculations.test.js b/src/lib/__tests__/calculations.test.js index 4e1973b..0b5e2b4 100644 --- a/src/lib/__tests__/calculations.test.js +++ b/src/lib/__tests__/calculations.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { isLongTerm, calcFee, paychecksBefore, expensesBefore, - obligationsBefore, calcRetirementNet, calcSummary, + obligationsBefore, calcCashFlow, calcRetirementNet, calcSummary, calcMonthlySavings, calcSavingsRate, fmt, fmtQty, } from "../calculations.js"; @@ -65,18 +65,17 @@ describe("calcFee", () => { }); describe("paychecksBefore", () => { + const beforeAll = "2026-02-01"; // _today before all test dates + it("counts biweekly paychecks correctly", () => { const config = { paycheckAmount: 5000, firstPayDate: "2026-03-06", paycheckFrequency: "biweekly" }; - // 3/6, 3/20 — 2 paychecks on or before 4/3 (next one is 4/3 but boundary may not include) - // Verify: sell date well past the 3rd paycheck - const count = paychecksBefore("2026-04-04", config); + const count = paychecksBefore("2026-04-04", config, beforeAll); expect(count).toBe(3); // 3/6, 3/20, 4/3 }); it("counts weekly paychecks", () => { const config = { paycheckAmount: 1000, firstPayDate: "2026-03-01", paycheckFrequency: "weekly" }; - // 3/1, 3/8 — sell date 3/15 captures up to boundary - const count = paychecksBefore("2026-03-16", config); + const count = paychecksBefore("2026-03-16", config, beforeAll); expect(count).toBe(3); // 3/1, 3/8, 3/15 }); @@ -84,28 +83,36 @@ describe("paychecksBefore", () => { expect(paychecksBefore("2026-04-01", {})).toBe(0); expect(paychecksBefore("2026-04-01", { paycheckAmount: 0 })).toBe(0); }); + + it("skips past paychecks already reflected in cash balance", () => { + const config = { paycheckAmount: 5000, firstPayDate: "2026-02-01", paycheckFrequency: "biweekly" }; + // With _today = 3/10, past paychecks (2/1, 2/15, 3/1) are skipped + // Only 3/15, 3/29 counted through 4/1 + const count = paychecksBefore("2026-04-01", config, "2026-03-10"); + expect(count).toBe(2); // 3/15, 3/29 + }); }); describe("expensesBefore", () => { + const beforeAll = "2025-12-01"; // _today before all test dates + it("calculates monthly expenses", () => { const expenses = [{ amount: 1000, frequency: "monthly", startDate: "2026-01-01" }]; // Jan, Feb, Mar = 3 months - const total = expensesBefore("2026-03-15", expenses); + const total = expensesBefore("2026-03-15", expenses, beforeAll); expect(total).toBe(3000); }); it("calculates weekly expenses", () => { const expenses = [{ amount: 100, frequency: "weekly", startDate: "2026-03-01" }]; - // 3/1, 3/8 = 2 weeks on or before 3/14; 3/1, 3/8, 3/15 = 3 by 3/16 - const total = expensesBefore("2026-03-16", expenses); - expect(total).toBe(300); + const total = expensesBefore("2026-03-16", expenses, beforeAll); + expect(total).toBe(300); // 3/1, 3/8, 3/15 }); it("calculates biweekly expenses", () => { const expenses = [{ amount: 200, frequency: "biweekly", startDate: "2026-03-01" }]; - // 3/1, 3/15 = 2 biweekly periods by 3/16 - const total = expensesBefore("2026-03-16", expenses); - expect(total).toBe(400); + const total = expensesBefore("2026-03-16", expenses, beforeAll); + expect(total).toBe(400); // 3/1, 3/15 }); it("handles multiple expenses of different frequencies", () => { @@ -113,7 +120,7 @@ describe("expensesBefore", () => { { amount: 1000, frequency: "monthly", startDate: "2026-03-01" }, { amount: 50, frequency: "weekly", startDate: "2026-03-01" }, ]; - const total = expensesBefore("2026-03-16", expenses); + const total = expensesBefore("2026-03-16", expenses, beforeAll); // Monthly: 1 (3/1), Weekly: 3 (3/1, 3/8, 3/15) expect(total).toBe(1000 + 150); }); @@ -121,6 +128,15 @@ describe("expensesBefore", () => { it("returns 0 for empty expenses", () => { expect(expensesBefore("2026-04-01", [])).toBe(0); }); + + it("skips past expenses already reflected in cash balance", () => { + const expenses = [{ amount: 1679, frequency: "monthly", startDate: "2026-01-01" }]; + // With _today = 3/3, Jan and Feb payments are past (already paid). + // Only Mar (3/1 is past → next is Apr) wait — 3/1 < 3/3, so advance to 4/1. + // Sell date 4/18: only 4/1 counted = 1 occurrence + const total = expensesBefore("2026-04-18", expenses, "2026-03-03"); + expect(total).toBe(1679); // only April payment + }); }); describe("obligationsBefore", () => { @@ -376,6 +392,168 @@ describe("calcMonthlySavings", () => { }); }); +describe("calcCashFlow", () => { + const baseCF = { + paycheckAmount: 5000, firstPayDate: "2026-03-06", paycheckFrequency: "biweekly", + spousePaycheckAmount: 0, spouseFirstPayDate: "", spousePaycheckFrequency: "biweekly", + expenses: [], oneTimeObligations: [], + }; + const today = "2026-02-01"; + + it("computes net from paychecks minus expenses minus obligations", () => { + const cf = { ...baseCF, expenses: [{ amount: 2000, frequency: "monthly", startDate: "2026-03-01" }] }; + const result = calcCashFlow("2026-04-17", cf, today); + expect(result.pays).toBeGreaterThan(0); + expect(result.payTotal).toBe(result.pays * 5000); + expect(result.expTotal).toBeGreaterThan(0); + expect(result.net).toBe(result.payTotal + result.spousePayTotal - result.expTotal - result.obTotal); + }); + + it("includes spouse paychecks in net", () => { + const cf = { + ...baseCF, + spousePaycheckAmount: 3000, + spouseFirstPayDate: "2026-03-06", + spousePaycheckFrequency: "biweekly", + }; + const result = calcCashFlow("2026-04-17", cf, today); + expect(result.spousePays).toBeGreaterThan(0); + expect(result.spousePayTotal).toBe(result.spousePays * 3000); + expect(result.net).toBe(result.payTotal + result.spousePayTotal - result.expTotal - result.obTotal); + }); + + it("returns zero spouse totals when no spouse config", () => { + const result = calcCashFlow("2026-04-17", baseCF, today); + expect(result.spousePays).toBe(0); + expect(result.spousePayTotal).toBe(0); + }); + + it("counts mortgageCount from monthly expenses only", () => { + const cf = { + ...baseCF, + expenses: [ + { amount: 1679, frequency: "monthly", startDate: "2026-03-01" }, + { amount: 100, frequency: "weekly", startDate: "2026-03-01" }, + ], + }; + const result = calcCashFlow("2026-04-17", cf, today); + expect(result.mortgageCount).toBeGreaterThan(0); + }); +}); + +describe("calcRetirementNet with liquidationPercent", () => { + it("applies partial liquidation to pre-tax account", () => { + const result = calcRetirementNet({ + enabled: true, penaltyRate: 0.10, taxRate: 0.24, stateTaxRate: 0, + accounts: [{ accountType: "pretax_401k", balance: 100000, contributions: 0, liquidationPercent: 50 }], + }); + expect(result.gross).toBe(50000); + expect(result.accounts[0].penalty).toBe(5000); + expect(result.accounts[0].tax).toBe(12000); + expect(result.accounts[0].net).toBe(33000); + }); + + it("applies partial liquidation to Roth — taxes only proportional earnings", () => { + const result = calcRetirementNet({ + enabled: true, penaltyRate: 0.10, taxRate: 0.24, stateTaxRate: 0, + accounts: [{ accountType: "roth_401k", balance: 20000, contributions: 15000, liquidationPercent: 50 }], + }); + // effBalance=10000, effContributions=7500, earnings=2500 + expect(result.accounts[0].penalty).toBe(250); + expect(result.accounts[0].tax).toBe(600); + expect(result.accounts[0].net).toBe(10000 - 250 - 600); + }); + + it("defaults to 100% when liquidationPercent is undefined", () => { + const result = calcRetirementNet({ + enabled: true, penaltyRate: 0.10, taxRate: 0.24, stateTaxRate: 0, + accounts: [{ accountType: "pretax_401k", balance: 10000, contributions: 0 }], + }); + expect(result.gross).toBe(10000); + }); +}); + +describe("calcSummary with liquidationPercent", () => { + const baseState = { + sellDate: "2026-04-17", + assets: [], + cashAccounts: [], + retirement: { enabled: false }, + taxConfig: { taxMode: "flat", ltcgRate: 0.15, stcgRate: 0.24, niitRate: 0.038, niitApplies: false }, + platforms: {}, + cashFlow: { + paycheckAmount: 0, firstPayDate: "", paycheckFrequency: "biweekly", + spousePaycheckAmount: 0, spouseFirstPayDate: "", spousePaycheckFrequency: "biweekly", + expenses: [], oneTimeObligations: [], + }, + }; + + it("applies liquidationPercent to asset gross/basis/fee", () => { + const state = { + ...baseState, + assets: [{ + id: "a1", quantity: 100, costBasis: 5000, acquisitionDate: "2024-01-01", + priceKey: "test", feeType: "none", liquidationPercent: 50, + }], + }; + const result = calcSummary(state, { test: 100 }); + // effectiveQty = 50, gross = 50 * 100 = 5000, effectiveBasis = 2500 + expect(result.rows[0].gross).toBe(5000); + expect(result.rows[0].gainLoss).toBe(2500); + }); + + it("defaults to 100% when liquidationPercent is missing", () => { + const state = { + ...baseState, + assets: [{ + id: "a1", quantity: 100, costBasis: 5000, acquisitionDate: "2024-01-01", + priceKey: "test", feeType: "none", + }], + }; + const result = calcSummary(state, { test: 100 }); + expect(result.rows[0].gross).toBe(10000); + expect(result.rows[0].gainLoss).toBe(5000); + }); +}); + +describe("calcMonthlySavings with spouse income", () => { + it("includes spouse biweekly income", () => { + const result = calcMonthlySavings({ + paycheckAmount: 5000, paycheckFrequency: "biweekly", + spousePaycheckAmount: 3000, spousePaycheckFrequency: "biweekly", + expenses: [], + }); + expect(result.monthlyIncome).toBeCloseTo((5000 + 3000) * 26 / 12, 2); + }); + + it("includes spouse weekly income", () => { + const result = calcMonthlySavings({ + paycheckAmount: 8000, paycheckFrequency: "monthly", + spousePaycheckAmount: 1000, spousePaycheckFrequency: "weekly", + expenses: [], + }); + expect(result.monthlyIncome).toBeCloseTo(8000 + 1000 * 52 / 12, 2); + }); + + it("includes spouse monthly income", () => { + const result = calcMonthlySavings({ + paycheckAmount: 5000, paycheckFrequency: "monthly", + spousePaycheckAmount: 4000, spousePaycheckFrequency: "monthly", + expenses: [], + }); + expect(result.monthlyIncome).toBe(9000); + }); + + it("ignores spouse when amount is 0", () => { + const result = calcMonthlySavings({ + paycheckAmount: 5000, paycheckFrequency: "monthly", + spousePaycheckAmount: 0, spousePaycheckFrequency: "biweekly", + expenses: [], + }); + expect(result.monthlyIncome).toBe(5000); + }); +}); + describe("calcSavingsRate", () => { it("returns null when income is 0", () => { expect(calcSavingsRate(0, 2000)).toBeNull(); diff --git a/src/lib/__tests__/storage.test.js b/src/lib/__tests__/storage.test.js index a21e480..c736874 100644 --- a/src/lib/__tests__/storage.test.js +++ b/src/lib/__tests__/storage.test.js @@ -56,6 +56,82 @@ describe("migrateState", () => { const result = migrateState(future); expect(result).toBe(future); }); + + it("v3→v4: ensures platforms object exists", () => { + const v3 = { schemaVersion: 3, assets: [], cashAccounts: [] }; + const result = migrateState(v3); + expect(result.schemaVersion).toBe(SCHEMA_VERSION); + expect(result.platforms).toEqual({}); + }); + + it("v3→v4: preserves existing platforms", () => { + const v3 = { schemaVersion: 3, assets: [], cashAccounts: [], platforms: { cs: { name: "CS", feePerShare: 0.10, flatFee: 10, feePercent: 0 } } }; + const result = migrateState(v3); + expect(result.platforms.cs.name).toBe("CS"); + }); + + it("v4→v5: adds spouse paycheck fields to cashFlow", () => { + const v4 = { schemaVersion: 4, assets: [], cashAccounts: [], cashFlow: { paycheckAmount: 5000 } }; + const result = migrateState(v4); + expect(result.schemaVersion).toBe(SCHEMA_VERSION); + expect(result.cashFlow.spousePaycheckAmount).toBe(0); + expect(result.cashFlow.spousePaycheckFrequency).toBe("biweekly"); + expect(result.cashFlow.spouseFirstPayDate).toBe(""); + expect(result.cashFlow.paycheckAmount).toBe(5000); + }); + + it("v4→v5: creates cashFlow when missing", () => { + const v4 = { schemaVersion: 4, assets: [], cashAccounts: [] }; + const result = migrateState(v4); + expect(result.cashFlow.spousePaycheckAmount).toBe(0); + expect(result.cashFlow.spousePaycheckFrequency).toBe("biweekly"); + }); + + it("v5→v6: adds liquidationPercent to assets", () => { + const v5 = { schemaVersion: 5, assets: [{ name: "GME", quantity: 10 }], cashAccounts: [] }; + const result = migrateState(v5); + expect(result.schemaVersion).toBe(SCHEMA_VERSION); + expect(result.assets[0].liquidationPercent).toBe(100); + }); + + it("v5→v6: adds liquidationPercent to retirement accounts", () => { + const v5 = { + schemaVersion: 5, assets: [], cashAccounts: [], + retirement: { accounts: [{ accountType: "pretax_401k", balance: 50000 }] }, + }; + const result = migrateState(v5); + expect(result.retirement.accounts[0].liquidationPercent).toBe(100); + }); + + it("v5→v6: initializes assets to empty array when missing", () => { + const v5 = { schemaVersion: 5, cashAccounts: [] }; + const result = migrateState(v5); + expect(Array.isArray(result.assets)).toBe(true); + expect(result.assets.length).toBe(0); + }); + + it("v5→v6: preserves existing liquidationPercent", () => { + const v5 = { schemaVersion: 5, assets: [{ name: "X", liquidationPercent: 75 }], cashAccounts: [] }; + const result = migrateState(v5); + expect(result.assets[0].liquidationPercent).toBe(75); + }); + + it("full migration v1→v6 works end to end", () => { + const v1 = { + schemaVersion: 1, + assets: [{ name: "GME", symbol: "GME", quantity: 10 }], + cashAccounts: [{ name: "Checking", balance: 5000 }], + purchase: { category: "home", homePrice: 350000 }, + platforms: { cs: { name: "ComputerShare", feePerShare: 0.10, flatFee: 10 } }, + cashFlow: { paycheckAmount: 5000 }, + }; + const result = migrateState(v1); + expect(result.schemaVersion).toBe(SCHEMA_VERSION); + expect(result.purchase.carMaintenanceAnnual).toBeNull(); + expect(result.platforms.cs.feePercent).toBe(0); + expect(result.cashFlow.spousePaycheckAmount).toBe(0); + expect(result.assets[0].liquidationPercent).toBe(100); + }); }); describe("validateImport", () => { diff --git a/src/lib/calculations.js b/src/lib/calculations.js index f7c8abd..06362c7 100644 --- a/src/lib/calculations.js +++ b/src/lib/calculations.js @@ -24,13 +24,17 @@ export function feeLabel(plat) { return parts.join(" + ") || "Free"; } -export function paychecksBefore(sellDate, cashFlowConfig) { +export function paychecksBefore(sellDate, cashFlowConfig, _today = null) { const { paycheckAmount, firstPayDate, paycheckFrequency } = cashFlowConfig; if (!paycheckAmount || !firstPayDate) return 0; const interval = paycheckFrequency === "weekly" ? 7 : paycheckFrequency === "monthly" ? 30 : 14; + const today = _today ? new Date(_today) : new Date(); + today.setHours(12, 0, 0, 0); let count = 0; let t = new Date(firstPayDate + "T12:00:00"); const sell = typeof sellDate === "string" ? new Date(sellDate + "T12:00:00") : sellDate; + // Skip past paychecks — they're already reflected in current cash balance + while (t < today) t = new Date(t.getTime() + interval * 864e5); while (t <= sell) { count++; t = new Date(t.getTime() + interval * 864e5); @@ -38,12 +42,16 @@ export function paychecksBefore(sellDate, cashFlowConfig) { return count; } -export function expensesBefore(sellDate, expenses) { +export function expensesBefore(sellDate, expenses, _today = null) { const sell = typeof sellDate === "string" ? new Date(sellDate + "T12:00:00") : sellDate; + const today = _today ? new Date(_today) : new Date(); + today.setHours(12, 0, 0, 0); let total = 0; for (const exp of expenses) { if (exp.frequency === "monthly") { let d = new Date(exp.startDate + "T12:00:00"); + // Skip past occurrences — already reflected in current cash balance + while (d < today) d.setMonth(d.getMonth() + 1); while (d <= sell) { total += exp.amount; d.setMonth(d.getMonth() + 1); @@ -51,6 +59,7 @@ export function expensesBefore(sellDate, expenses) { } else if (exp.frequency === "biweekly" || exp.frequency === "weekly") { const interval = exp.frequency === "weekly" ? 7 : 14; let d = new Date(exp.startDate + "T12:00:00"); + while (d < today) d = new Date(d.getTime() + interval * 864e5); while (d <= sell) { total += exp.amount; d = new Date(d.getTime() + interval * 864e5); @@ -71,11 +80,23 @@ export function obligationsBefore(sellDate, obligations) { return total; } -export function calcCashFlow(sellDate, cashFlowConfig) { - const pays = paychecksBefore(sellDate, cashFlowConfig); +export function calcCashFlow(sellDate, cashFlowConfig, _today = null) { + const pays = paychecksBefore(sellDate, cashFlowConfig, _today); const payTotal = pays * cashFlowConfig.paycheckAmount; - const expTotal = expensesBefore(sellDate, cashFlowConfig.expenses || []); + + // Spouse paychecks + const spouseConfig = { + paycheckAmount: cashFlowConfig.spousePaycheckAmount, + firstPayDate: cashFlowConfig.spouseFirstPayDate, + paycheckFrequency: cashFlowConfig.spousePaycheckFrequency || "biweekly", + }; + const spousePays = paychecksBefore(sellDate, spouseConfig, _today); + const spousePayTotal = spousePays * (cashFlowConfig.spousePaycheckAmount || 0); + + const expTotal = expensesBefore(sellDate, cashFlowConfig.expenses || [], _today); const obTotal = obligationsBefore(sellDate, cashFlowConfig.oneTimeObligations || []); + const today = _today ? new Date(_today) : new Date(); + today.setHours(12, 0, 0, 0); const mortgageCount = cashFlowConfig.expenses?.length > 0 ? (() => { let count = 0; @@ -83,13 +104,14 @@ export function calcCashFlow(sellDate, cashFlowConfig) { for (const exp of cashFlowConfig.expenses) { if (exp.frequency === "monthly") { let d = new Date(exp.startDate + "T12:00:00"); + while (d < today) d.setMonth(d.getMonth() + 1); while (d <= sell) { count++; d.setMonth(d.getMonth() + 1); } } } return count; })() : 0; - return { pays, payTotal, expTotal, obTotal, mortgageCount, net: payTotal - expTotal - obTotal }; + return { pays, payTotal, spousePays, spousePayTotal, expTotal, obTotal, mortgageCount, net: payTotal + spousePayTotal - expTotal - obTotal }; } export function calcRetirementNet(retirement) { @@ -104,25 +126,28 @@ export function calcRetirementNet(retirement) { for (const acct of accounts) { const { accountType, balance, contributions = 0 } = acct; + const liqPct = (acct.liquidationPercent ?? 100) / 100; + const effBalance = balance * liqPct; + const effContributions = contributions * liqPct; let penalty = 0, tax = 0; if (accountType === "roth_401k" || accountType === "roth_ira") { - // Roth: only earnings (balance - contributions) are penalized and taxed - const earnings = Math.max(0, balance - contributions); + // Roth: only the earnings portion of the liquidated amount is penalized/taxed + const earnings = Math.max(0, effBalance - effContributions); penalty = earnings * penaltyRate; tax = earnings * incomeTaxRate; } else { // Pre-tax 401k, Traditional IRA, Safe Harbor, Unknown: full balance - penalty = balance * penaltyRate; - tax = balance * incomeTaxRate; + penalty = effBalance * penaltyRate; + tax = effBalance * incomeTaxRate; } const deductions = penalty + tax; - totalGross += balance; + totalGross += effBalance; totalDeductions += deductions; accountResults.push({ - ...acct, penalty, tax, deductions, net: balance - deductions, + ...acct, penalty, tax, deductions, net: effBalance - deductions, }); } @@ -144,11 +169,14 @@ export function calcSummary(state, prices) { let ltGains = 0, ltLosses = 0, stGains = 0, stLosses = 0, totalFees = 0, totalNetProceeds = 0; const rows = (state.assets || []).map(asset => { + const liqPct = (asset.liquidationPercent ?? 100) / 100; const price = asset.priceKey ? (prices[asset.priceKey] || 0) : 0; - const gross = asset.priceKey === null ? asset.costBasis : asset.quantity * price; - const fee = calcFee(asset, gross, platforms); + const effectiveQty = (asset.quantity ?? 0) * liqPct; + const gross = asset.priceKey === null ? asset.costBasis * liqPct : effectiveQty * price; + const effectiveBasis = asset.costBasis * liqPct; + const fee = calcFee({ ...asset, quantity: effectiveQty }, gross, platforms); const net = gross - fee; - const gainLoss = gross - asset.costBasis; + const gainLoss = gross - effectiveBasis; const lt = isLongTerm(asset.acquisitionDate, sell); totalFees += fee; @@ -237,6 +265,14 @@ export function calcMonthlySavings(cashFlowConfig) { else if (paycheckFrequency === "biweekly") monthlyIncome = paycheckAmount * 26 / 12; else monthlyIncome = paycheckAmount; // monthly } + // Spouse income + const spa = cashFlowConfig.spousePaycheckAmount || 0; + if (spa) { + const spf = cashFlowConfig.spousePaycheckFrequency || "biweekly"; + if (spf === "weekly") monthlyIncome += spa * 52 / 12; + else if (spf === "biweekly") monthlyIncome += spa * 26 / 12; + else monthlyIncome += spa; + } let monthlyExpenses = 0; for (const exp of (expenses || [])) { if (exp.frequency === "weekly") monthlyExpenses += exp.amount * 52 / 12; diff --git a/src/lib/storage.js b/src/lib/storage.js index cd2fc36..49e7dd2 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -183,5 +183,42 @@ export function migrateState(state) { data.schemaVersion = 3; } + // v3 → v4: platform fees default empty (existing users keep theirs) + if (data.schemaVersion < 4) { + if (!data.platforms) data.platforms = {}; + data.schemaVersion = 4; + } + + // v4 → v5: add spouse paycheck fields + if (data.schemaVersion < 5) { + const cf = data.cashFlow || {}; + data.cashFlow = { + ...cf, + spousePaycheckAmount: cf.spousePaycheckAmount ?? 0, + spousePaycheckFrequency: cf.spousePaycheckFrequency ?? "biweekly", + spouseFirstPayDate: cf.spouseFirstPayDate ?? "", + }; + data.schemaVersion = 5; + } + + // v5 → v6: add liquidationPercent to assets and retirement accounts + if (data.schemaVersion < 6) { + if (!Array.isArray(data.assets)) data.assets = []; + data.assets = data.assets.map(a => ({ + ...a, + liquidationPercent: a.liquidationPercent ?? 100, + })); + if (Array.isArray(data.retirement?.accounts)) { + data.retirement = { + ...data.retirement, + accounts: data.retirement.accounts.map(a => ({ + ...a, + liquidationPercent: a.liquidationPercent ?? 100, + })), + }; + } + data.schemaVersion = 6; + } + return data; } diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index f1f1f80..7c8821b 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -131,7 +131,14 @@ export default function Dashboard({ state, prices, setPrice, updateState }) { {/* Cash Flow Cards */}
{[ - { l: "Paychecks", s: `${c.cashFlow.pays} × $${state.cashFlow.paycheckAmount.toLocaleString()}`, v: c.cashFlow.payTotal, clr: colors.green }, + { + l: "Paychecks", + s: c.cashFlow.spousePayTotal > 0 + ? `You: ${c.cashFlow.pays} · Spouse: ${c.cashFlow.spousePays}` + : `${c.cashFlow.pays} × $${state.cashFlow.paycheckAmount.toLocaleString()}`, + v: c.cashFlow.payTotal + c.cashFlow.spousePayTotal, + clr: colors.green, + }, { l: "Expenses", s: `${c.cashFlow.mortgageCount} months`, v: -c.cashFlow.expTotal, clr: colors.red }, { l: "Obligations", s: "One-time", v: -c.cashFlow.obTotal, clr: colors.red }, { l: "Net Cash Flow", s: fmtDate(c.sellDate), v: c.cashFlow.net, clr: c.cashFlow.net >= 0 ? colors.green : colors.red }, @@ -169,10 +176,11 @@ export default function Dashboard({ state, prices, setPrice, updateState }) { - {["Platform", "Asset", "Qty", "Price", "Gross", "Basis", "Gain/Loss", "Fees", "Net", ""].map(h => ( + {["Platform", "Asset", "Qty", "Price", "Liq %", "Gross", "Basis", "Gain/Loss", "Fees", "Net", ""].map(h => ( ))} @@ -183,7 +191,7 @@ export default function Dashboard({ state, prices, setPrice, updateState }) { - + @@ -216,6 +224,21 @@ export default function Dashboard({ state, prices, setPrice, updateState }) { /> )} + @@ -237,6 +260,24 @@ export default function Dashboard({ state, prices, setPrice, updateState }) { {Math.round((state.retirement.penaltyRate + state.retirement.taxRate + state.retirement.stateTaxRate) * 100)}% {(acct.accountType === "roth_401k" || acct.accountType === "roth_ira") ? " (earnings)" : ""} + @@ -245,6 +286,27 @@ export default function Dashboard({ state, prices, setPrice, updateState }) { ))} + + {/* Capital sales */} + {(state.capitalSales || []).map((sale, i) => { + const gainLoss = (sale.expectedAmount || 0) - (sale.costBasis || 0); + const glColor = gainLoss > 0.01 ? colors.green : gainLoss < -0.01 ? colors.red : colors.dim; + return ( + + + + + + + + + + + + ); + })}
{h}
{ca.platform} {ca.name} {fmt(ca.balance)} {fmt(ca.balance)} + updateState(prev => ({ + ...prev, + assets: prev.assets.map(x => x.id === a.id ? { ...x, liquidationPercent: Math.min(100, Math.max(0, Number.parseInt(e.target.value) || 0)) } : x), + }))} + style={{ + width: 52, background: colors.bgInput, border: `1px solid ${colors.border}`, + color: (a.liquidationPercent ?? 100) < 100 ? colors.amber : colors.dim, + textAlign: "right", padding: "3px 4px", borderRadius: 4, + fontFamily: "'IBM Plex Mono', monospace", fontSize: 13, + }} + /> + {fmt(a.gross)} {fmt(a.costBasis)} {fmt(a.gainLoss)} + updateState(prev => ({ + ...prev, + retirement: { + ...prev.retirement, + accounts: prev.retirement.accounts.map(x => x.id === acct.id ? { ...x, liquidationPercent: Math.min(100, Math.max(0, Number.parseInt(e.target.value) || 0)) } : x), + }, + }))} + style={{ + width: 52, background: colors.bgInput, border: `1px solid ${colors.border}`, + color: (acct.liquidationPercent ?? 100) < 100 ? colors.amber : colors.dim, + textAlign: "right", padding: "3px 4px", borderRadius: 4, + fontFamily: "'IBM Plex Mono', monospace", fontSize: 13, + }} + /> + {fmt(acct.balance)} {fmt(acct.deductions)} {fmt(-acct.deductions)}Ret
{sale.saleDate || "—"}{sale.name}{fmt(sale.expectedAmount)}{fmt(sale.costBasis)}{fmt(gainLoss)}{fmt(sale.expectedAmount)} + {sale.isLongTerm ? "LT" : "ST"} +
diff --git a/src/pages/Import.jsx b/src/pages/Import.jsx index 0375708..3448281 100644 --- a/src/pages/Import.jsx +++ b/src/pages/Import.jsx @@ -8,7 +8,7 @@ import { parseTransamericaCSV } from "../lib/parsers/transamerica.js"; import { parsePayPalCSV, applyPayPalAnnotations } from "../lib/parsers/paypal.js"; import { detectColumnMappings, applyColumnMapping } from "../lib/parsers/custom.js"; import { PROVIDERS } from "../data/providers.js"; -import { uuid } from "../data/defaults.js"; +import { uuid, KNOWN_PLATFORM_FEES } from "../data/defaults.js"; import { fmt, fmtQty } from "../lib/calculations.js"; const PLATFORM_OPTIONS = Object.entries(PROVIDERS).map(([value, p]) => ({ value, label: p.label })); @@ -237,6 +237,15 @@ export default function Import({ updateState }) { next = { ...next, cashAccounts: [...cashAccounts, ...newCash] }; } + // Auto-populate platform fees for known platforms + const PLATFORM_FEE_MAP = { + ComputerShare: "cs", Gemini: "gem", PayPal: "pp", Fidelity: "fidelity", + }; + const feeKey = PLATFORM_FEE_MAP[parsed.platform]; + if (feeKey && KNOWN_PLATFORM_FEES[feeKey] && !next.platforms?.[feeKey]) { + next = { ...next, platforms: { ...next.platforms, [feeKey]: { ...KNOWN_PLATFORM_FEES[feeKey] } } }; + } + return next; }); @@ -632,7 +641,7 @@ function ParsedPreview({ parsed, onConfirm, onCancel, btnStyle }) { {["Name", "Symbol", "Quantity", "Cost Basis", "Acquired", "Fee Type", "Notes"].map(h => ( {h} ))} @@ -641,13 +650,13 @@ function ParsedPreview({ parsed, onConfirm, onCancel, btnStyle }) { {parsed.assets.map((a, i) => ( - {a.name} - {a.symbol} - {fmtQty(a.quantity)} - {fmt(a.costBasis)} - {a.acquisitionDate || "—"} - {a.feeType} - {a.notes || ""} + {a.name} + {a.symbol} + {fmtQty(a.quantity)} + {fmt(a.costBasis)} + {a.acquisitionDate || "—"} + {a.feeType} + {a.notes || ""} ))} diff --git a/src/pages/Projections.jsx b/src/pages/Projections.jsx index beeb0bd..3547d26 100644 --- a/src/pages/Projections.jsx +++ b/src/pages/Projections.jsx @@ -67,7 +67,14 @@ export default function Projections({ state, updateState }) { {/* Summary cards */}
{[ - { l: "Paychecks", s: `${cf.pays} × $${state.cashFlow.paycheckAmount?.toLocaleString() || 0}`, v: cf.payTotal, clr: colors.green }, + { + l: "Paychecks", + s: cf.spousePayTotal > 0 + ? `You: ${cf.pays} · Spouse: ${cf.spousePays}` + : `${cf.pays} × $${state.cashFlow.paycheckAmount?.toLocaleString() || 0}`, + v: cf.payTotal + cf.spousePayTotal, + clr: colors.green, + }, { l: "Expenses", s: `${cf.mortgageCount} occurrences`, v: -cf.expTotal, clr: colors.red }, { l: "Obligations", s: "One-time due", v: -cf.obTotal, clr: colors.red }, { l: "Net Cash Flow", s: `by ${fmtDate(sellDate)}`, v: cf.net, clr: cf.net >= 0 ? colors.green : colors.red }, @@ -82,7 +89,7 @@ export default function Projections({ state, updateState }) { {/* Paycheck config */}
-
Paycheck Config
+
Your Paycheck
@@ -104,6 +111,32 @@ export default function Projections({ state, updateState }) {
+ {/* Spouse paycheck config */} +
+
Spouse Paycheck
+
+
+ + updateCashFlow("spousePaycheckAmount", parseFloat(e.target.value) || 0)} style={inputStyle} + placeholder="0 = none" /> +
+
+ + +
+
+ + updateCashFlow("spouseFirstPayDate", e.target.value)} style={inputStyle} /> +
+
+
Leave amount at 0 if no spouse income.
+
+ {/* Recurring Expenses */}
diff --git a/src/pages/SetupWizard.jsx b/src/pages/SetupWizard.jsx index 1ac3a57..1ebc6ce 100644 --- a/src/pages/SetupWizard.jsx +++ b/src/pages/SetupWizard.jsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { colors, fonts, styles } from "../theme.js"; -import { DEFAULT_TAX_CONFIG, uuid } from "../data/defaults.js"; +import { DEFAULT_TAX_CONFIG, uuid, YAHOO_TICKERS, GEMINI_TICKERS, COINGECKO_TICKERS } from "../data/defaults.js"; import { STATE_TAXES } from "../data/stateTaxes.js"; import { loanTypeForCategory } from "../lib/purchasePlanner.js"; import { track } from "../lib/analytics.js"; @@ -18,6 +18,7 @@ const STEPS = [ { key: "welcome", label: "Welcome" }, { key: "income", label: "Income" }, { key: "cash", label: "Cash & Savings" }, + { key: "investments", label: "Investments" }, { key: "expenses", label: "Expenses" }, { key: "obligations", label: "Cash Events" }, { key: "tax", label: "Tax & Date" }, @@ -28,6 +29,7 @@ const STEPS = [ export default function SetupWizard({ updateState }) { const [step, setStep] = useState(0); const [hasExpenses, setHasExpenses] = useState(null); // null = not answered, true/false + const [hasInvestments, setHasInvestments] = useState(null); const [hasObligations, setHasObligations] = useState(null); const [draft, setDraft] = useState({ paycheckAmount: 0, @@ -37,6 +39,7 @@ export default function SetupWizard({ updateState }) { cashAccounts: [], oneTimeObligations: [], capitalSales: [], + assets: [], taxConfig: { ...DEFAULT_TAX_CONFIG }, sellDate: new Date().toISOString().slice(0, 10), purchaseCategory: null, @@ -86,11 +89,15 @@ export default function SetupWizard({ updateState }) { paycheckAmount: draft.paycheckAmount, paycheckFrequency: draft.paycheckFrequency, firstPayDate: draft.firstPayDate, + spousePaycheckAmount: draft.hasSpouseIncome ? (draft.spousePaycheckAmount || 0) : 0, + spousePaycheckFrequency: draft.spousePaycheckFrequency || "biweekly", + spouseFirstPayDate: draft.hasSpouseIncome ? (draft.spouseFirstPayDate || "") : "", expenses: draft.expenses, oneTimeObligations: draft.oneTimeObligations, }, cashAccounts: draft.cashAccounts, capitalSales: draft.capitalSales, + assets: [...(prev.assets || []), ...draft.assets.filter(a => a.name || a.symbol)], dateOfBirth: draft.dateOfBirth, taxConfig: { ...prev.taxConfig, ...draft.taxConfig, combinedW2 }, purchase: cat @@ -108,9 +115,8 @@ export default function SetupWizard({ updateState }) { // Skip optional steps when user answers "No" const goNext = () => { let next = step + 1; - // Skip expenses detail if user said no + if (STEPS[next]?.key === "investments" && hasInvestments === false) next++; if (STEPS[next]?.key === "expenses" && hasExpenses === false) next++; - // Skip obligations detail if user said no if (STEPS[next]?.key === "obligations" && hasObligations === false) next++; setStep(next); }; @@ -119,6 +125,7 @@ export default function SetupWizard({ updateState }) { let prev = step - 1; if (STEPS[prev]?.key === "obligations" && hasObligations === false) prev--; if (STEPS[prev]?.key === "expenses" && hasExpenses === false) prev--; + if (STEPS[prev]?.key === "investments" && hasInvestments === false) prev--; setStep(Math.max(0, prev)); }; @@ -170,6 +177,21 @@ export default function SetupWizard({ updateState }) { {currentStep.key === "cash" && ( )} + {currentStep.key === "investments" && ( + { + setHasInvestments(val); + if (!val) goNext(); + else if (draft.assets.length === 0) + setDraft(prev => ({ ...prev, assets: [{ id: uuid(), name: "", symbol: "", acquisitionDate: "", costBasis: 0, quantity: 0 }] })); + }} + question="Do you have investments to track?" + hint="Stocks, crypto, ETFs, or other assets. You can also import from CSV/XLSX on the Import page after setup." + > + + + )} {currentStep.key === "expenses" && ( update("firstPayDate", e.target.value)} style={inputStyle} />
+ + {/* Spouse income */} +
+ + {draft.hasSpouseIncome && ( +
+
+ + update("spousePaycheckAmount", parseFloat(e.target.value) || 0)} + style={inputStyle} /> +
+
+ + +
+
+ + update("spouseFirstPayDate", e.target.value)} style={inputStyle} /> +
+
+ )} +
); } @@ -353,6 +408,93 @@ function CashStep({ draft, setDraft, inputStyle, labelStyle, addBtnStyle, remove ); } +function derivePriceKey(symbol) { + if (!symbol) return null; + const key = symbol.toLowerCase().trim(); + if (YAHOO_TICKERS[key]) return key; + if (GEMINI_TICKERS[key]) return key; + if (COINGECKO_TICKERS[key]) return key; + return null; +} + +function InvestmentsStep({ draft, setDraft, inputStyle, labelStyle, addBtnStyle, removeBtnStyle }) { + const [lastAddedId, setLastAddedId] = useState(() => draft.assets.length === 1 ? draft.assets[0].id : null); + const add = () => { + const newId = uuid(); + setDraft(prev => ({ + ...prev, + assets: [...prev.assets, { id: newId, name: "", symbol: "", acquisitionDate: "", costBasis: 0, quantity: 0 }], + })); + setLastAddedId(newId); + }; + const remove = (id) => { + setDraft(prev => ({ ...prev, assets: prev.assets.filter(a => a.id !== id) })); + }; + const set = (id, key, value) => { + setDraft(prev => ({ + ...prev, + assets: prev.assets.map(a => { + if (a.id !== id) return a; + const updated = { ...a, [key]: value }; + if (key === "symbol") { + updated.priceKey = derivePriceKey(value); + } + return updated; + }), + })); + }; + + return ( +
+
+
+
INVESTMENTS
+
Stocks, crypto, ETFs, or other tracked assets.
+
+ +
+ {draft.assets.map((asset) => ( +
+
+ + set(asset.id, "name", e.target.value)} + placeholder="GameStop" style={inputStyle} autoFocus={asset.id === lastAddedId} /> +
+
+ + set(asset.id, "symbol", e.target.value)} + placeholder="GME" style={{ ...inputStyle, textTransform: "uppercase" }} /> + {asset.symbol && !derivePriceKey(asset.symbol) && ( +
Unknown ticker
+ )} +
+
+ + set(asset.id, "acquisitionDate", e.target.value)} style={inputStyle} /> +
+
+ + set(asset.id, "costBasis", parseFloat(e.target.value) || 0)} style={inputStyle} /> +
+
+ + set(asset.id, "quantity", parseFloat(e.target.value) || 0)} style={inputStyle} /> +
+ +
+ ))} + {draft.assets.length === 0 && ( +
+ No investments yet. Click "+ Add" to add manually, or import from CSV/XLSX after setup. +
+ )} +
+ ); +} + function ExpensesStep({ draft, setDraft, inputStyle, labelStyle, addBtnStyle, removeBtnStyle }) { const [lastAddedId, setLastAddedId] = useState(() => draft.expenses.length === 1 ? draft.expenses[0].id : null); const add = () => {