diff --git a/src/App.css b/src/App.css index 479e672..59eaa1f 100644 --- a/src/App.css +++ b/src/App.css @@ -531,13 +531,14 @@ .gl-sidebar { width: 220px; - min-height: 100vh; + height: 100vh; background: var(--gl-card); border-right: 1px solid var(--gl-border); padding: 20px 0; display: flex; flex-direction: column; flex-shrink: 0; + overflow-y: auto; } .gl-sidebar-brand { @@ -700,6 +701,7 @@ .gl-main { flex: 1; + min-height: 0; padding: 26px 42px; overflow-y: auto; } @@ -744,6 +746,7 @@ .gl-sidebar { width: 100% !important; + height: auto !important; min-height: auto !important; border-right: none !important; border-bottom: 1px solid var(--gl-border); diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 52caa85..4cec00e 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -48,7 +48,7 @@ export default function Layout({ sellDate, onSellDateChange, purchaseDate, onPur : null; return ( -
+
{/* Sidebar */} {/* Main content */} -
+
{/* Top bar */}
diff --git a/src/data/defaults.js b/src/data/defaults.js index ac10699..cd5777b 100644 --- a/src/data/defaults.js +++ b/src/data/defaults.js @@ -26,10 +26,10 @@ export const COINGECKO_TICKERS = { }; export const DEFAULT_PLATFORMS = { - cs: { name: "ComputerShare", feePerShare: 0.10, flatFee: 10 }, - gem: { name: "Gemini", feePercent: 0.015 }, - pp: { name: "Paypal", feePercent: 0.02 }, - fidelity: { name: "Fidelity", feePercent: 0 }, + 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 }, }; export const DEFAULT_TAX_CONFIG = { @@ -123,7 +123,7 @@ export const DEFAULT_READINESS = { assetAppreciationRate: 0, }; -export const SCHEMA_VERSION = 2; +export const SCHEMA_VERSION = 3; export function createDefaultState() { return { diff --git a/src/lib/__tests__/calculations.test.js b/src/lib/__tests__/calculations.test.js index e8f0e8c..4e1973b 100644 --- a/src/lib/__tests__/calculations.test.js +++ b/src/lib/__tests__/calculations.test.js @@ -25,8 +25,9 @@ describe("isLongTerm", () => { describe("calcFee", () => { const platforms = { - cs: { name: "ComputerShare", feePerShare: 0.10, flatFee: 10 }, - gem: { name: "Gemini", feePercent: 0.015 }, + cs: { name: "ComputerShare", feePerShare: 0.10, flatFee: 10, feePercent: 0 }, + gem: { name: "Gemini", feePerShare: 0, flatFee: 0, feePercent: 0.015 }, + combo: { name: "Combo", feePerShare: 0.05, flatFee: 5, feePercent: 0.01 }, }; it("calculates per-share fee + flat fee", () => { @@ -39,6 +40,17 @@ describe("calcFee", () => { expect(calcFee(asset, 10000, platforms)).toBe(150); }); + it("calculates combined per-share + flat + percentage fee", () => { + const asset = { quantity: 200, feeType: "combo" }; + // 200 * 0.05 + 5 + 0.01 * 8000 = 10 + 5 + 80 = 95 + expect(calcFee(asset, 8000, platforms)).toBe(95); + }); + + it("handles undefined quantity without NaN", () => { + const asset = { feeType: "cs" }; + expect(calcFee(asset, 5000, platforms)).toBe(10); // 0 * 0.10 + 10 + 0 * 5000 + }); + it("returns 0 for feeType none", () => { expect(calcFee({ feeType: "none" }, 5000, platforms)).toBe(0); }); @@ -179,7 +191,7 @@ describe("calcSummary", () => { ltcgRate: 0.15, stcgRate: 0.24, niitRate: 0.038, niitApplies: false, standardDeduction: 31400, }, - platforms: { gem: { name: "Gemini", feePercent: 0.015 } }, + platforms: { gem: { name: "Gemini", feePerShare: 0, flatFee: 0, feePercent: 0.015 } }, cashFlow: { paycheckAmount: 5000, firstPayDate: "2026-03-06", paycheckFrequency: "biweekly", expenses: [], oneTimeObligations: [], diff --git a/src/lib/__tests__/storage.test.js b/src/lib/__tests__/storage.test.js index c9c725d..a21e480 100644 --- a/src/lib/__tests__/storage.test.js +++ b/src/lib/__tests__/storage.test.js @@ -9,13 +9,6 @@ describe("migrateState", () => { expect(migrateState(state)).toBe(state); }); - it("migrates v0 state to current version preserving data", () => { - const result = migrateState({ schemaVersion: 0, purchase: { category: "home" } }); - expect(result.schemaVersion).toBe(SCHEMA_VERSION); - expect(result.purchase.category).toBe("home"); - expect(result.purchase.carMaintenanceAnnual).toBeNull(); - }); - it("resets to defaults for empty/unrecognizable state", () => { const result = migrateState({}); expect(result.schemaVersion).toBe(SCHEMA_VERSION); @@ -27,30 +20,41 @@ describe("migrateState", () => { expect(result.dateOfBirth).toEqual({ month: "", year: "" }); }); - it("v1 state: adds carMaintenanceAnnual: null to purchase", () => { + it("migrates v1 state: adds carMaintenanceAnnual and normalizes platforms", () => { const v1State = { schemaVersion: 1, - assets: [], - cashAccounts: [], + 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 }, gem: { name: "Gemini", feePercent: 0.015 } }, }; const result = migrateState(v1State); expect(result.schemaVersion).toBe(SCHEMA_VERSION); expect(result.purchase.carMaintenanceAnnual).toBeNull(); expect(result.purchase.homePrice).toBe(350000); + expect(result.assets[0].name).toBe("GME"); + expect(result.cashAccounts[0].balance).toBe(5000); + expect(result.platforms.cs).toEqual({ name: "ComputerShare", feePerShare: 0.10, flatFee: 10, feePercent: 0 }); + expect(result.platforms.gem).toEqual({ name: "Gemini", feePerShare: 0, flatFee: 0, feePercent: 0.015 }); }); - it("v1 state: other fields preserved intact", () => { - const v1State = { - schemaVersion: 1, - assets: [{ name: "GME", symbol: "GME", quantity: 10 }], - cashAccounts: [{ name: "Checking", balance: 5000 }], - purchase: { category: "vehicle", carPrice: 35000 }, + it("migrates v2 state: normalizes platforms to three-field format", () => { + const v2State = { + schemaVersion: 2, + assets: [], + purchase: { category: "vehicle", carMaintenanceAnnual: 500 }, + platforms: { pp: { name: "Paypal", feePercent: 0.02 } }, }; - const result = migrateState(v1State); - expect(result.assets).toHaveLength(1); - expect(result.assets[0].name).toBe("GME"); - expect(result.cashAccounts[0].balance).toBe(5000); + const result = migrateState(v2State); + expect(result.schemaVersion).toBe(SCHEMA_VERSION); + expect(result.purchase.carMaintenanceAnnual).toBe(500); + expect(result.platforms.pp).toEqual({ name: "Paypal", feePerShare: 0, flatFee: 0, feePercent: 0.02 }); + }); + + it("returns future-versioned state as-is", () => { + const future = { schemaVersion: SCHEMA_VERSION + 1, assets: [{ name: "X" }] }; + const result = migrateState(future); + expect(result).toBe(future); }); }); @@ -63,8 +67,8 @@ describe("validateImport", () => { })).not.toThrow(); }); - it("accepts old backups without schemaVersion if they have data", () => { - expect(() => validateImport({ assets: [{ name: "GME", symbol: "GME" }] })).not.toThrow(); + it("rejects backups without schemaVersion even if they have data", () => { + expect(() => validateImport({ assets: [{ name: "GME", symbol: "GME" }] })).toThrow("no schemaVersion"); }); it("rejects non-object inputs", () => { @@ -75,7 +79,7 @@ describe("validateImport", () => { }); it("rejects objects that don't look like GreenLight data", () => { - expect(() => validateImport({ foo: "bar" })).toThrow("doesn't look like a GreenLight backup"); + expect(() => validateImport({ foo: "bar" })).toThrow("no schemaVersion"); }); it("rejects invalid schemaVersion", () => { diff --git a/src/lib/calculations.js b/src/lib/calculations.js index b75ce2f..f7c8abd 100644 --- a/src/lib/calculations.js +++ b/src/lib/calculations.js @@ -13,9 +13,15 @@ export function calcFee(asset, grossValue, platforms) { if (ft === "none" || !ft) return 0; const plat = platforms[ft]; if (!plat) return 0; - if (plat.feePerShare != null) return asset.quantity * plat.feePerShare + (plat.flatFee || 0); - if (plat.feePercent != null) return grossValue * plat.feePercent; - return 0; + return (plat.feePerShare || 0) * (asset.quantity ?? 0) + (plat.flatFee || 0) + (plat.feePercent || 0) * grossValue; +} + +export function feeLabel(plat) { + const parts = []; + if (plat.feePerShare) parts.push(`$${plat.feePerShare}/sh`); + if (plat.flatFee) parts.push(`$${plat.flatFee} flat`); + if (plat.feePercent) parts.push(`${(plat.feePercent * 100).toFixed(1)}%`); + return parts.join(" + ") || "Free"; } export function paychecksBefore(sellDate, cashFlowConfig) { diff --git a/src/lib/storage.js b/src/lib/storage.js index 6577e68..cd2fc36 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -96,18 +96,16 @@ export function validateImport(obj) { } // Must have a schema version (any GreenLight export has one) - if (obj.schemaVersion == null && obj.assets == null && obj.cashAccounts == null) { - throw new Error("This doesn't look like a GreenLight backup (no schemaVersion or data found)"); + if (obj.schemaVersion == null) { + throw new Error("This doesn't look like a GreenLight backup (no schemaVersion found)"); } // Schema version sanity check - if (obj.schemaVersion != null) { - if (typeof obj.schemaVersion !== "number" || obj.schemaVersion < 1) { - throw new Error(`Invalid schemaVersion: ${obj.schemaVersion}`); - } - if (obj.schemaVersion > SCHEMA_VERSION + 5) { - throw new Error(`This backup is from a newer version (v${obj.schemaVersion}). Update GreenLight first.`); - } + if (typeof obj.schemaVersion !== "number" || obj.schemaVersion < 1) { + throw new Error(`Invalid schemaVersion: ${obj.schemaVersion}`); + } + if (obj.schemaVersion > SCHEMA_VERSION + 5) { + throw new Error(`This backup is from a newer version (v${obj.schemaVersion}). Update GreenLight first.`); } // Validate array fields are actually arrays @@ -152,26 +150,38 @@ export function migrateState(state) { // Already current — return as-is (preserves object reference) if (state.schemaVersion === SCHEMA_VERSION) return state; + // Future version — return as-is with warning + if (state.schemaVersion > SCHEMA_VERSION) { + console.warn(`[GreenLight] State has future schema v${state.schemaVersion} (current: ${SCHEMA_VERSION}). Returning as-is.`); + return state; + } + let data = { ...state }; // v1 → v2: add carMaintenanceAnnual to purchase if ((data.schemaVersion ?? 0) < 2) { - if (data.purchase) { - if (data.purchase.carMaintenanceAnnual === undefined) { - data.purchase = { ...data.purchase, carMaintenanceAnnual: null }; - } + if (data.purchase && data.purchase.carMaintenanceAnnual === undefined) { + data.purchase = { ...data.purchase, carMaintenanceAnnual: null }; } data.schemaVersion = 2; } - // Safety net: if still not at current version after all migrations - if (data.schemaVersion !== SCHEMA_VERSION) { - if (data.schemaVersion > SCHEMA_VERSION) { - console.warn(`[GreenLight] State has future schema v${data.schemaVersion} (current: ${SCHEMA_VERSION}). Returning as-is.`); - return data; + // v2 → v3: normalize platform fee fields (all three always present) + if (data.schemaVersion < 3) { + if (data.platforms) { + const migrated = {}; + for (const [key, plat] of Object.entries(data.platforms)) { + migrated[key] = { + name: plat.name, + feePerShare: plat.feePerShare ?? 0, + flatFee: plat.flatFee ?? 0, + feePercent: plat.feePercent ?? 0, + }; + } + data.platforms = migrated; } - console.error(`[GreenLight] Migration failed: expected v${SCHEMA_VERSION}, got v${data.schemaVersion}. Resetting.`); - return createDefaultState(); + data.schemaVersion = 3; } + return data; } diff --git a/src/pages/Assets.jsx b/src/pages/Assets.jsx index 21154bc..0fa1739 100644 --- a/src/pages/Assets.jsx +++ b/src/pages/Assets.jsx @@ -1,6 +1,6 @@ import React, { useState, useMemo } from "react"; import { colors, styles } from "../theme.js"; -import { calcSummary, calcRetirementNet, fmt, fmtQty } from "../lib/calculations.js"; +import { calcSummary, calcRetirementNet, feeLabel, fmt, fmtQty } from "../lib/calculations.js"; import { RETIREMENT_ACCOUNT_TYPES, uuid } from "../data/defaults.js"; const EMPTY_ASSET = { @@ -185,7 +185,7 @@ export default function Assets({ state, updateState, prices }) { {plat.name}: - {plat.feePerShare != null ? `$${plat.feePerShare}/sh + $${plat.flatFee} bulk` : `${(plat.feePercent * 100).toFixed(1)}%`} + {feeLabel(plat)} ))} diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx index 32010bb..5e7e982 100644 --- a/src/pages/Settings.jsx +++ b/src/pages/Settings.jsx @@ -1,6 +1,6 @@ import { useState, useMemo } from "react"; import { colors, styles } from "../theme.js"; -import { createDefaultState, createSeededState } from "../data/defaults.js"; +import { createDefaultState } from "../data/defaults.js"; import { exportState, importState } from "../lib/storage.js"; import { getConsent, setConsent, track } from "../lib/analytics.js"; import { STATE_TAXES } from "../data/stateTaxes.js"; @@ -51,7 +51,7 @@ export default function Settings({ state, updateState, replaceState }) { if (state.platforms[k]) { setPlatformKeyError(`Key "${k}" already exists.`); return; } updateState(prev => ({ ...prev, - platforms: { ...prev.platforms, [k]: { name: newPlatformName.trim(), feePercent: 0 } }, + platforms: { ...prev.platforms, [k]: { name: newPlatformName.trim(), feePerShare: 0, flatFee: 0, feePercent: 0 } }, })); setNewPlatformKey(""); setNewPlatformName(""); @@ -174,12 +174,6 @@ export default function Settings({ state, updateState, replaceState }) { } }; - const handleSeed = () => { - if (confirm("Replace all data with example data? This will overwrite current data.")) { - replaceState(createSeededState()); - } - }; - const labelStyle = { fontSize: 13, color: colors.dim, textTransform: "uppercase", letterSpacing: 1, display: "block", marginBottom: 4 }; const inputStyle = styles.input; const btnStyle = { ...styles.btn, padding: "8px 18px", fontSize: 15 }; @@ -351,34 +345,26 @@ export default function Settings({ state, updateState, replaceState }) { )}
{Object.entries(state.platforms).map(([key, plat]) => ( -
+
updatePlatform(key, "name", e.target.value)} style={inputStyle} />
- {plat.feePerShare != null ? ( - <> -
- - updatePlatform(key, "feePerShare", parseFloat(e.target.value) || 0)} style={inputStyle} /> -
-
- - updatePlatform(key, "flatFee", parseFloat(e.target.value) || 0)} style={inputStyle} /> -
- - ) : ( - <> -
- - updatePlatform(key, "feePercent", parseFloat(e.target.value) || 0)} style={inputStyle} /> -
-
{(plat.feePercent * 100).toFixed(1)}%
- - )} +
+ + updatePlatform(key, "feePerShare", Math.max(0, parseFloat(e.target.value) || 0))} style={inputStyle} /> +
+
+ + updatePlatform(key, "flatFee", Math.max(0, parseFloat(e.target.value) || 0))} style={inputStyle} /> +
+
+ + updatePlatform(key, "feePercent", Math.max(0, parseFloat(e.target.value) || 0))} style={inputStyle} /> +
Key: {key}
@@ -491,7 +477,6 @@ export default function Settings({ state, updateState, replaceState }) { Import -