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
5 changes: 4 additions & 1 deletion src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -700,6 +701,7 @@

.gl-main {
flex: 1;
min-height: 0;
padding: 26px 42px;
overflow-y: auto;
}
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/components/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default function Layout({ sellDate, onSellDateChange, purchaseDate, onPur
: null;

return (
<div className="gl-layout" style={{ fontFamily: fonts.mono, background: colors.bg, color: colors.text, minHeight: "100vh", display: "flex" }}>
<div className="gl-layout" style={{ fontFamily: fonts.mono, background: colors.bg, color: colors.text, height: "100vh", display: "flex", overflow: "hidden" }}>

{/* Sidebar */}
<nav className="gl-sidebar">
Expand Down Expand Up @@ -111,7 +111,7 @@ export default function Layout({ sellDate, onSellDateChange, purchaseDate, onPur
</nav>

{/* Main content */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0, minHeight: 0, overflow: "hidden" }}>

{/* Top bar */}
<header className="gl-header">
Expand Down
10 changes: 5 additions & 5 deletions src/data/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -123,7 +123,7 @@ export const DEFAULT_READINESS = {
assetAppreciationRate: 0,
};

export const SCHEMA_VERSION = 2;
export const SCHEMA_VERSION = 3;

export function createDefaultState() {
return {
Expand Down
18 changes: 15 additions & 3 deletions src/lib/__tests__/calculations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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);
});
Expand Down Expand Up @@ -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: [],
Expand Down
50 changes: 27 additions & 23 deletions src/lib/__tests__/storage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
});
});

Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
12 changes: 9 additions & 3 deletions src/lib/calculations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
50 changes: 30 additions & 20 deletions src/lib/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
4 changes: 2 additions & 2 deletions src/pages/Assets.jsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -185,7 +185,7 @@ export default function Assets({ state, updateState, prices }) {
<React.Fragment key={key}>
<span style={{ color: colors.dim, fontSize: 14 }}>{plat.name}:</span>
<span style={{ textAlign: "right", fontSize: 14 }}>
{plat.feePerShare != null ? `$${plat.feePerShare}/sh + $${plat.flatFee} bulk` : `${(plat.feePercent * 100).toFixed(1)}%`}
{feeLabel(plat)}
</span>
</React.Fragment>
))}
Expand Down
Loading