First version mostly built

This commit is contained in:
Deven Thiel 2026-03-05 17:04:52 -05:00
parent 27bb45f7df
commit 99a3dbd73c
42 changed files with 9443 additions and 3338 deletions

15
index.html Normal file
View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ten99timecard — 1099 Income & Tax Tracker</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Serif+JP:wght@400;700&family=Orbitron:wght@400;700&family=Bangers&family=Comic+Neue:wght@400;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3673
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,18 +2,37 @@
"name": "ten99timecard",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Income logging, projections, and quarterly tax calculations for US 1099 workers",
"license": "MIT",
"workspaces": [
"client",
"server"
],
"scripts": {
"dev": "npm run dev --workspace=client",
"dev:server": "npm run dev --workspace=server",
"build": "npm run build --workspace=client && npm run build --workspace=server",
"test": "npm run test --workspace=client && npm run test --workspace=server",
"test:client": "npm run test --workspace=client",
"test:server": "npm run test --workspace=server"
"dev": "node --max-http-header-size=81920 node_modules/.bin/vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"zustand": "^4.5.5",
"recharts": "^2.12.7",
"date-fns": "^3.6.0",
"clsx": "^2.1.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.4",
"@vitest/coverage-v8": "^2.1.4",
"@testing-library/react": "^16.0.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/user-event": "^14.5.2",
"jsdom": "^25.0.1"
}
}

99
src/App.tsx Normal file
View file

@ -0,0 +1,99 @@
import { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom';
import { useAppStore } from '@/store/appStore';
import { ThemeProvider } from '@/themes/ThemeProvider';
import { DashboardPage } from '@/pages/DashboardPage';
import { LedgerPage } from '@/pages/LedgerPage';
import { TaxPage } from '@/pages/TaxPage';
import { TimerPage } from '@/pages/TimerPage';
import { SettingsPage } from '@/pages/SettingsPage';
export function App() {
const saving = useAppStore((s) => s.saving);
const saveError = useAppStore((s) => s.lastSaveError);
const init = useAppStore((s) => s.init);
const [menuOpen, setMenuOpen] = useState(false);
const closeMenu = () => setMenuOpen(false);
const [kofiOpen, setKofiOpen] = useState(false);
useEffect(() => { init(); }, []);
useEffect(() => {
if (!kofiOpen) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setKofiOpen(false); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [kofiOpen]);
return (
<ThemeProvider>
<BrowserRouter>
<div className="app-shell">
<header className="app-header">
<span className="logo">ten99timecard</span>
<nav className={`app-nav${menuOpen ? ' open' : ''}`}>
<NavLink to="/" end onClick={closeMenu}>Dashboard</NavLink>
<NavLink to="/work" onClick={closeMenu}>Work</NavLink>
<NavLink to="/payments" onClick={closeMenu}>Payments</NavLink>
<NavLink to="/expenses" onClick={closeMenu}>Expenses</NavLink>
<NavLink to="/tax" onClick={closeMenu}>Tax</NavLink>
<NavLink to="/timer" onClick={closeMenu}>Timer</NavLink>
<NavLink to="/settings" onClick={closeMenu}>Settings</NavLink>
</nav>
<div className="flex items-center gap-2 text-sm header-status">
{saving && <span className="text-muted">Saving</span>}
{saveError && <span className="text-danger" title={saveError}> Save failed</span>}
</div>
<button className="kofi-header-btn" onClick={() => setKofiOpen(true)}>
Donate
</button>
<button
className="hamburger-btn"
onClick={() => setMenuOpen((o) => !o)}
aria-label="Toggle menu"
>
{menuOpen ? '✕' : '☰'}
</button>
</header>
{menuOpen && <div className="nav-overlay" onClick={closeMenu} />}
{kofiOpen && (
<div className="kofi-overlay" onClick={() => setKofiOpen(false)}>
<div className="kofi-modal" onClick={(e) => e.stopPropagation()}>
<div className="kofi-modal-header">
<span>Support ten99timecard </span>
<button className="kofi-modal-close" onClick={() => setKofiOpen(false)} aria-label="Close"></button>
</div>
<iframe
id="kofiframe"
src="https://ko-fi.com/deltathiel/?hidefeed=true&widget=true&embed=true&preview=true"
style={{ border: 'none', width: '100%', padding: '4px' }}
height="712"
title="ten99timecard"
/>
</div>
</div>
)}
<main className="app-body">
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/work" element={<LedgerPage initialTab="work" />} />
<Route path="/payments" element={<LedgerPage initialTab="payments" />} />
<Route path="/expenses" element={<LedgerPage initialTab="expenses" />} />
<Route path="/tax" element={<TaxPage />} />
<Route path="/timer" element={<TimerPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
<footer className="app-footer">
<span>© {new Date().getFullYear()} Delta Thiel. All rights reserved.</span>
<span className="app-footer-sep">·</span>
<span>Not tax advice. For planning purposes only.</span>
</footer>
</div>
</BrowserRouter>
</ThemeProvider>
);
}

View file

@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useAppStore } from '@/store/appStore';
describe('appStore — CRUD', () => {
beforeEach(() => {
useAppStore.setState({
data: {
workEntries: [],
payments: [],
expenses: [],
taxInputs: {},
dashboard: { charts: [], widgets: [] },
settings: { theme: 'standard', mode: 'dark', defaultRate: 50 },
version: 1,
},
vault: null,
});
});
it('addWorkEntry assigns id and timestamps', () => {
const e = useAppStore.getState().addWorkEntry({
date: '2024-01-01', description: 'code review', amount: 150,
});
expect(e.id).toBeTruthy();
expect(e.createdAt).toBeGreaterThan(0);
expect(useAppStore.getState().data.workEntries).toHaveLength(1);
});
it('updateWorkEntry patches fields', () => {
const e = useAppStore.getState().addWorkEntry({
date: '2024-01-01', description: 'old', amount: 100,
});
useAppStore.getState().updateWorkEntry(e.id, { description: 'new' });
const updated = useAppStore.getState().data.workEntries[0];
expect(updated.description).toBe('new');
expect(updated.amount).toBe(100);
expect(updated.updatedAt).toBeGreaterThanOrEqual(e.createdAt);
});
it('deleteWorkEntry removes it', () => {
const e = useAppStore.getState().addWorkEntry({
date: '2024-01-01', description: 'x', amount: 1,
});
useAppStore.getState().deleteWorkEntry(e.id);
expect(useAppStore.getState().data.workEntries).toHaveLength(0);
});
it('addPayment / addExpense follow same pattern', () => {
const p = useAppStore.getState().addPayment({
date: '2024-01-01', amount: 5000, payer: 'Acme',
});
const ex = useAppStore.getState().addExpense({
date: '2024-01-01', amount: 200, description: 'laptop', deductible: true,
});
expect(p.id).toBeTruthy();
expect(ex.id).toBeTruthy();
expect(useAppStore.getState().data.payments).toHaveLength(1);
expect(useAppStore.getState().data.expenses).toHaveLength(1);
});
it('mutations bump version counter', () => {
const v0 = useAppStore.getState().data.version;
useAppStore.getState().addWorkEntry({ date: '2024-01-01', description: 'x' });
expect(useAppStore.getState().data.version).toBe(v0 + 1);
});
it('setTaxInputs merges per-year', () => {
useAppStore.getState().setTaxInputs(2024, { priorYearAGI: 50000 });
useAppStore.getState().setTaxInputs(2024, { priorYearTax: 6000 });
const ti = useAppStore.getState().data.taxInputs[2024];
expect(ti.priorYearAGI).toBe(50000);
expect(ti.priorYearTax).toBe(6000);
expect(ti.filingStatus).toBe('single');
});
it('chart add/update/remove', () => {
useAppStore.getState().addChart({ title: 'test chart' });
const charts = useAppStore.getState().data.dashboard.charts;
expect(charts).toHaveLength(1);
const id = charts[0].id;
useAppStore.getState().updateChart(id, { type: 'bar' });
expect(useAppStore.getState().data.dashboard.charts[0].type).toBe('bar');
useAppStore.getState().removeChart(id);
expect(useAppStore.getState().data.dashboard.charts).toHaveLength(0);
});
it('setTheme updates both theme and mode', () => {
useAppStore.getState().setTheme('cyberpunk', 'light');
expect(useAppStore.getState().data.settings.theme).toBe('cyberpunk');
expect(useAppStore.getState().data.settings.mode).toBe('light');
});
it('setDefaultRate updates settings', () => {
useAppStore.getState().setDefaultRate(85);
expect(useAppStore.getState().data.settings.defaultRate).toBe(85);
});
});

View file

@ -0,0 +1,239 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HierSpreadsheet } from '@/components/spreadsheet/HierSpreadsheet';
import { WorkEntryForm, ExpenseForm } from '@/components/spreadsheet/EntryForm';
import { Modal, ConfirmDialog } from '@/components/common/Modal';
import type { HierNode } from '@/types';
// ─── HierSpreadsheet ─────────────────────────────────────────────────────────
describe('HierSpreadsheet', () => {
const tree: HierNode[] = [
{
key: '2024', level: 'year', label: '2024', value: 1500,
children: [
{
key: '2024-03', level: 'month', label: 'March 2024', value: 1500,
children: [
{
key: '2024-03-15', level: 'day', label: 'Mar 15', value: 1500,
children: [
{ key: 'i1', level: 'item', label: 'Task A', value: 1000, children: [] },
{ key: 'i2', level: 'item', label: 'Task B', value: 500, children: [] },
],
},
],
},
],
},
];
it('shows only top-level rows by default', () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
expect(screen.getByText('2024')).toBeInTheDocument();
expect(screen.queryByText('March 2024')).not.toBeInTheDocument();
});
it('expands row on click', async () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
await userEvent.click(screen.getByText('2024'));
expect(screen.getByText('March 2024')).toBeInTheDocument();
});
it('collapses expanded row on second click', async () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
await userEvent.click(screen.getByText('2024'));
expect(screen.getByText('March 2024')).toBeInTheDocument();
await userEvent.click(screen.getByText('2024'));
expect(screen.queryByText('March 2024')).not.toBeInTheDocument();
});
it('Item-level button expands entire tree', async () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
await userEvent.click(screen.getByRole('button', { name: 'Item' }));
expect(screen.getByText('Task A')).toBeInTheDocument();
expect(screen.getByText('Task B')).toBeInTheDocument();
});
it('Year button collapses everything', async () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
await userEvent.click(screen.getByRole('button', { name: 'Item' }));
await userEvent.click(screen.getByRole('button', { name: 'Year' }));
expect(screen.queryByText('Task A')).not.toBeInTheDocument();
});
it('Month button expands years but not days', async () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
await userEvent.click(screen.getByRole('button', { name: 'Month' }));
expect(screen.getByText('March 2024')).toBeInTheDocument();
expect(screen.queryByText('Mar 15')).not.toBeInTheDocument();
});
it('displays grand total', () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
expect(screen.getByText('Grand Total')).toBeInTheDocument();
expect(screen.getAllByText('$1,500.00').length).toBeGreaterThanOrEqual(1);
});
it('calls onEdit for item rows', async () => {
const onEdit = vi.fn();
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" onEdit={onEdit} />);
await userEvent.click(screen.getByRole('button', { name: 'Item' }));
const editBtns = screen.getAllByTitle('Edit');
await userEvent.click(editBtns[0]);
expect(onEdit).toHaveBeenCalledTimes(1);
});
it('shows empty state', () => {
render(<HierSpreadsheet nodes={[]} valueLabel="Amount" />);
expect(screen.getByText(/No entries yet/)).toBeInTheDocument();
});
});
// ─── WorkEntryForm ───────────────────────────────────────────────────────────
describe('WorkEntryForm', () => {
const numberInputs = (container: HTMLElement) =>
Array.from(container.querySelectorAll('input[type="number"]')) as HTMLInputElement[];
it('submits flat amount', async () => {
const onSubmit = vi.fn();
const { container } = render(<WorkEntryForm defaultRate={50} onSubmit={onSubmit} onCancel={() => {}} />);
await userEvent.type(screen.getByPlaceholderText(/What did you work/), 'Code review');
const [amountInput] = numberInputs(container);
await userEvent.type(amountInput, '150');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
description: 'Code review',
amount: 150,
}));
expect(onSubmit.mock.calls[0][0].hours).toBeUndefined();
});
it('submits hours × rate', async () => {
const onSubmit = vi.fn();
const { container } = render(<WorkEntryForm defaultRate={75} onSubmit={onSubmit} onCancel={() => {}} />);
await userEvent.click(screen.getByRole('button', { name: /Time × rate/ }));
await userEvent.type(screen.getByPlaceholderText(/What did you work/), 'Dev work');
const [hoursInput] = numberInputs(container);
await userEvent.type(hoursInput, '4');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
hours: 4,
rate: 75,
}));
expect(onSubmit.mock.calls[0][0].amount).toBeUndefined();
});
it('shows computed value preview for time mode', async () => {
const { container } = render(<WorkEntryForm defaultRate={50} onSubmit={() => {}} onCancel={() => {}} />);
await userEvent.click(screen.getByRole('button', { name: /Time × rate/ }));
const [hoursInput] = numberInputs(container);
await userEvent.type(hoursInput, '3');
expect(screen.getByText(/\$150\.00/)).toBeInTheDocument();
});
it('pre-fills from initial data', () => {
render(
<WorkEntryForm
initial={{ date: '2024-03-15', description: 'existing', amount: 200 }}
defaultRate={50}
onSubmit={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByDisplayValue('existing')).toBeInTheDocument();
expect(screen.getByDisplayValue('200')).toBeInTheDocument();
});
});
// ─── ExpenseForm ─────────────────────────────────────────────────────────────
describe('ExpenseForm', () => {
it('includes deductible checkbox defaulting to true', () => {
render(<ExpenseForm onSubmit={() => {}} onCancel={() => {}} />);
const cb = screen.getByRole('checkbox');
expect(cb).toBeChecked();
});
it('submits with deductible toggled off', async () => {
const onSubmit = vi.fn();
const { container } = render(<ExpenseForm onSubmit={onSubmit} onCancel={() => {}} />);
const textInputs = container.querySelectorAll('input:not([type="date"]):not([type="number"]):not([type="checkbox"])');
const numInputs = container.querySelectorAll('input[type="number"]');
await userEvent.type(textInputs[0] as HTMLInputElement, 'coffee');
await userEvent.type(numInputs[0] as HTMLInputElement, '5');
await userEvent.click(screen.getByRole('checkbox'));
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
description: 'coffee',
amount: 5,
deductible: false,
}));
});
});
// ─── Modal ───────────────────────────────────────────────────────────────────
describe('Modal', () => {
it('renders when open', () => {
render(<Modal open title="Test" onClose={() => {}}>body</Modal>);
expect(screen.getByText('Test')).toBeInTheDocument();
expect(screen.getByText('body')).toBeInTheDocument();
});
it('does not render when closed', () => {
render(<Modal open={false} title="Hidden" onClose={() => {}}>body</Modal>);
expect(screen.queryByText('Hidden')).not.toBeInTheDocument();
});
it('closes on Escape key', () => {
const onClose = vi.fn();
render(<Modal open title="Test" onClose={onClose}>body</Modal>);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
it('closes on overlay click but not content click', () => {
const onClose = vi.fn();
render(<Modal open title="Test" onClose={onClose}>body</Modal>);
fireEvent.click(screen.getByText('body'));
expect(onClose).not.toHaveBeenCalled();
});
});
describe('ConfirmDialog', () => {
it('calls onConfirm when confirmed', async () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<ConfirmDialog
open
title="Sure?"
message="really?"
confirmLabel="Yes"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await userEvent.click(screen.getByRole('button', { name: 'Yes' }));
expect(onConfirm).toHaveBeenCalled();
expect(onCancel).not.toHaveBeenCalled();
});
it('calls onCancel when cancelled', async () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<ConfirmDialog open title="t" message="m" onConfirm={onConfirm} onCancel={onCancel} />,
);
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { encrypt, decrypt, verifyPassword } from '@/lib/crypto/encryption';
describe('encryption', () => {
it('encrypts and decrypts round-trip', async () => {
const plaintext = JSON.stringify({ hello: 'world', n: 42 });
const ct = await encrypt(plaintext, 'my-password');
expect(ct).not.toContain('hello');
expect(ct).not.toBe(plaintext);
const back = await decrypt(ct, 'my-password');
expect(back).toBe(plaintext);
});
it('throws on wrong password', async () => {
const ct = await encrypt('secret data', 'correct-password');
await expect(decrypt(ct, 'wrong-password')).rejects.toThrow();
});
it('produces different ciphertext each time (random salt+iv)', async () => {
const ct1 = await encrypt('same plaintext', 'same-password');
const ct2 = await encrypt('same plaintext', 'same-password');
expect(ct1).not.toBe(ct2);
// But both decrypt to the same thing
expect(await decrypt(ct1, 'same-password')).toBe(await decrypt(ct2, 'same-password'));
});
it('handles unicode content', async () => {
const text = '日本語テスト 🎉 émojis';
const ct = await encrypt(text, 'pw');
expect(await decrypt(ct, 'pw')).toBe(text);
});
it('handles large payloads', async () => {
const big = 'x'.repeat(100_000);
const ct = await encrypt(big, 'pw');
expect(await decrypt(ct, 'pw')).toBe(big);
});
it('verifyPassword returns true for correct password', async () => {
const ct = await encrypt('data', 'correct');
expect(await verifyPassword(ct, 'correct')).toBe(true);
});
it('verifyPassword returns false for wrong password (no throw)', async () => {
const ct = await encrypt('data', 'correct');
expect(await verifyPassword(ct, 'wrong')).toBe(false);
});
it('output is valid base64', async () => {
const ct = await encrypt('test', 'pw');
expect(() => atob(ct)).not.toThrow();
});
});

View file

@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { fmtMoney, fmtDuration, fmtDurationVerbose, totalMinutes, msToHours } from '@/lib/format';
describe('fmtMoney', () => {
it('formats dollars with 2 decimals', () => {
expect(fmtMoney(1234.5)).toBe('$1,234.50');
expect(fmtMoney(0)).toBe('$0.00');
});
it('handles null/undefined', () => {
expect(fmtMoney(null)).toBe('—');
expect(fmtMoney(undefined)).toBe('—');
});
it('handles negative values', () => {
expect(fmtMoney(-500)).toContain('500');
});
});
describe('fmtDuration', () => {
it('formats H:MM:SS', () => {
expect(fmtDuration(0)).toBe('0:00:00');
expect(fmtDuration(65_000)).toBe('0:01:05');
expect(fmtDuration(3_661_000)).toBe('1:01:01'); // 1h 1m 1s
expect(fmtDuration(10 * 3_600_000)).toBe('10:00:00');
});
it('floors fractional seconds', () => {
expect(fmtDuration(1_999)).toBe('0:00:01');
});
});
describe('fmtDurationVerbose', () => {
it('formats Xh Xm Xs', () => {
expect(fmtDurationVerbose(3_661_000)).toBe('1h 1m 1s');
expect(fmtDurationVerbose(0)).toBe('0h 0m 0s');
});
});
describe('totalMinutes', () => {
it('computes h*60 + m ignoring seconds', () => {
expect(totalMinutes(3_661_000)).toBe(61); // 1h 1m 1s → 61 min
expect(totalMinutes(59_000)).toBe(0); // 59s → 0 min
expect(totalMinutes(90 * 60_000)).toBe(90); // 90 min
});
});
describe('msToHours', () => {
it('converts milliseconds to decimal hours', () => {
expect(msToHours(3_600_000)).toBe(1);
expect(msToHours(1_800_000)).toBe(0.5);
expect(msToHours(5_400_000)).toBe(1.5);
});
});

50
src/__tests__/setup.ts Normal file
View file

@ -0,0 +1,50 @@
import '@testing-library/jest-dom';
import { webcrypto } from 'node:crypto';
import { beforeEach } from 'vitest';
// jsdom lacks WebCrypto subtle — polyfill from Node
if (!globalThis.crypto?.subtle) {
Object.defineProperty(globalThis, 'crypto', { value: webcrypto });
}
// jsdom lacks ResizeObserver (needed by Recharts)
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;
// structuredClone polyfill for older jsdom
if (!globalThis.structuredClone) {
globalThis.structuredClone = (o: unknown) => JSON.parse(JSON.stringify(o));
}
// Silence "not implemented: navigation" warnings from jsdom
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
if (typeof args[0] === 'string' && args[0].includes('Not implemented')) return;
originalWarn(...args);
};
// jsdom 25.x backs localStorage with a file store that lacks clear().
// Replace it with a plain in-memory implementation for tests.
const store: Record<string, string> = {};
const localStorageMock: Storage = {
getItem: (k) => store[k] ?? null,
setItem: (k, v) => { store[k] = String(v); },
removeItem: (k) => { delete store[k]; },
clear: () => { Object.keys(store).forEach((k) => delete store[k]); },
get length() { return Object.keys(store).length; },
key: (i) => Object.keys(store)[i] ?? null,
};
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
// Reset cookies and localStorage between tests
beforeEach(() => {
document.cookie.split(';').forEach((c) => {
const name = c.split('=')[0].trim();
if (name) document.cookie = `${name}=; max-age=0; path=/`;
});
localStorage.clear();
});

256
src/__tests__/stats.test.ts Normal file
View file

@ -0,0 +1,256 @@
import { describe, it, expect } from 'vitest';
import { aggregate, buildHierarchy, buildChartSeries } from '@/lib/stats/aggregate';
import { workEntryValue } from '@/types';
import type { WorkEntry, Payment, Expense } from '@/types';
const mkWork = (date: string, amount?: number, hours?: number, rate?: number): WorkEntry => ({
id: `w-${date}-${amount ?? hours}`, date,
description: 'work', amount, hours, rate,
createdAt: 0, updatedAt: 0,
});
const mkPayment = (date: string, amount: number): Payment => ({
id: `p-${date}-${amount}`, date, amount, payer: 'X', createdAt: 0, updatedAt: 0,
});
const mkExpense = (date: string, amount: number, deductible = true): Expense => ({
id: `e-${date}-${amount}`, date, amount, description: 'exp', deductible,
createdAt: 0, updatedAt: 0,
});
describe('workEntryValue', () => {
it('returns amount when set', () => {
expect(workEntryValue(mkWork('2024-01-01', 150))).toBe(150);
});
it('returns hours * rate when amount not set', () => {
expect(workEntryValue(mkWork('2024-01-01', undefined, 4, 50))).toBe(200);
});
it('amount takes precedence over hours/rate', () => {
const e = mkWork('2024-01-01', 100, 10, 50);
expect(workEntryValue(e)).toBe(100);
});
it('returns 0 when neither set', () => {
expect(workEntryValue(mkWork('2024-01-01'))).toBe(0);
});
});
describe('aggregate', () => {
it('rolls up days → months → years', () => {
const agg = aggregate(
[],
[mkPayment('2024-01-15', 1000), mkPayment('2024-01-20', 500), mkPayment('2024-02-10', 300)],
[],
);
expect(agg.days.get('2024-01-15')?.payments).toBe(1000);
expect(agg.months.get('2024-01')?.payments).toBe(1500);
expect(agg.months.get('2024-02')?.payments).toBe(300);
expect(agg.years.find((y) => y.label === '2024')?.payments).toBe(1800);
});
it('computes net = payments expenses', () => {
const agg = aggregate(
[],
[mkPayment('2024-03-01', 5000)],
[mkExpense('2024-03-01', 1200)],
);
expect(agg.days.get('2024-03-01')?.net).toBe(3800);
expect(agg.months.get('2024-03')?.net).toBe(3800);
});
it('separates deductible from total expenses', () => {
const agg = aggregate(
[],
[],
[mkExpense('2024-03-01', 100, true), mkExpense('2024-03-01', 50, false)],
);
const d = agg.days.get('2024-03-01')!;
expect(d.expenses).toBe(150);
expect(d.deductibleExpenses).toBe(100);
});
it('computes monthly average per active day', () => {
const agg = aggregate(
[],
[mkPayment('2024-03-01', 100), mkPayment('2024-03-10', 200)],
[],
);
// 2 active days, 300 total → 150 avg
expect(agg.months.get('2024-03')?.avgPerChild).toBe(150);
expect(agg.months.get('2024-03')?.childCount).toBe(2);
});
it('computes yearly average per active month', () => {
const agg = aggregate(
[],
[mkPayment('2024-01-05', 1000), mkPayment('2024-03-05', 2000)],
[],
);
const y = agg.years.find((y) => y.label === '2024')!;
// 2 months active, 3000 net → 1500 avg/month
expect(y.childCount).toBe(2);
expect(y.avgPerChild).toBe(1500);
});
it('projects year-end from YTD (halfway through year)', () => {
const agg = aggregate(
[],
[mkPayment('2024-01-01', 50000)],
[],
new Date('2024-07-01'), // ~halfway
);
const y = agg.years.find((y) => y.label === '2024')!;
expect(y.projected).toBeGreaterThan(90000);
expect(y.projected).toBeLessThan(110000);
});
it('does not project completed years', () => {
const agg = aggregate([], [mkPayment('2023-06-01', 1000)], [], new Date('2024-06-01'));
const y = agg.years.find((y) => y.label === '2023')!;
expect(y.projected).toBeNull();
});
it('projects month-end from current day', () => {
const agg = aggregate(
[],
[mkPayment('2024-06-10', 1000)],
[],
new Date('2024-06-15'), // halfway through June
);
const m = agg.months.get('2024-06')!;
expect(m.projected).toBeGreaterThan(1500);
expect(m.projected).toBeLessThan(2500);
});
it('sorts years descending', () => {
const agg = aggregate(
[],
[mkPayment('2022-01-01', 1), mkPayment('2024-01-01', 1), mkPayment('2023-01-01', 1)],
[],
);
expect(agg.years.map((y) => y.label)).toEqual(['2024', '2023', '2022']);
});
});
describe('buildHierarchy', () => {
it('builds year→month→day→item tree', () => {
const work = [
mkWork('2024-03-15', 100),
mkWork('2024-03-15', 50),
mkWork('2024-04-01', 200),
];
const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), (e) => (e as WorkEntry).description);
expect(tree).toHaveLength(1); // one year
expect(tree[0].level).toBe('year');
expect(tree[0].value).toBe(350);
expect(tree[0].children).toHaveLength(2); // two months
const march = tree[0].children.find((m) => m.key === '2024-03')!;
expect(march.value).toBe(150);
expect(march.children).toHaveLength(1); // one day
expect(march.children[0].children).toHaveLength(2); // two items
});
it('attaches entry to item leaves', () => {
const work = [mkWork('2024-01-01', 100)];
const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), () => 'label');
const item = tree[0].children[0].children[0].children[0];
expect(item.level).toBe('item');
expect(item.entry).toBe(work[0]);
});
it('sorts years, months, days descending (newest first)', () => {
const work = [mkWork('2023-01-01', 1), mkWork('2024-01-01', 1)];
const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), () => '');
expect(tree[0].key).toBe('2024');
expect(tree[1].key).toBe('2023');
});
});
describe('buildChartSeries', () => {
it('produces points at requested granularity', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-01-15', 100), mkPayment('2024-02-10', 200)],
[],
['payments'],
'month',
null,
null,
);
expect(series).toHaveLength(2);
expect(series[0].payments).toBe(100);
expect(series[1].payments).toBe(200);
});
it('filters by date range', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-01-01', 1), mkPayment('2024-06-01', 1), mkPayment('2024-12-01', 1)],
[],
['payments'],
'month',
'2024-03',
'2024-09',
);
expect(series).toHaveLength(1);
expect(series[0].label).toBe('2024-06');
});
it('computes cumulative metrics', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-01-01', 100), mkPayment('2024-02-01', 200), mkPayment('2024-03-01', 300)],
[],
['cumulativePayments'],
'month',
null,
null,
);
expect(series[0].cumulativePayments).toBe(100);
expect(series[1].cumulativePayments).toBe(300);
expect(series[2].cumulativePayments).toBe(600);
});
it('sorts ascending for time-series display', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-03-01', 1), mkPayment('2024-01-01', 1)],
[],
['payments'],
'month',
null,
null,
);
expect(series[0].label < series[1].label).toBe(true);
});
it('groups by week when requested', () => {
const series = buildChartSeries(
[],
// Monday and Tuesday of same week
[mkPayment('2024-06-03', 100), mkPayment('2024-06-04', 50)],
[],
['payments'],
'week',
null,
null,
);
expect(series).toHaveLength(1);
expect(series[0].payments).toBe(150);
});
it('computes netIncome metric', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-01-01', 1000)],
[mkExpense('2024-01-01', 300)],
['netIncome'],
'month',
null,
null,
);
expect(series[0].netIncome).toBe(700);
});
});

View file

@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CookieStorage } from '@/lib/storage/adapters';
import { Vault } from '@/lib/storage/vault';
import type { AppData } from '@/types';
describe('CookieStorage', () => {
it('saves and loads a small blob', async () => {
const cs = new CookieStorage('testuser');
await cs.save('hello-world-encrypted-blob');
const loaded = await cs.load();
expect(loaded).toBe('hello-world-encrypted-blob');
});
it('chunks large blobs across multiple cookies', async () => {
const cs = new CookieStorage('big');
const big = 'A'.repeat(10000); // > chunk size
await cs.save(big);
const loaded = await cs.load();
expect(loaded).toBe(big);
});
it('isolates namespaces', async () => {
const alice = new CookieStorage('alice');
const bob = new CookieStorage('bob');
await alice.save('alice-data');
await bob.save('bob-data');
expect(await alice.load()).toBe('alice-data');
expect(await bob.load()).toBe('bob-data');
});
it('returns null when nothing stored', async () => {
const cs = new CookieStorage('empty');
expect(await cs.load()).toBeNull();
});
it('clear removes all chunks', async () => {
const cs = new CookieStorage('clearme');
await cs.save('some-data-here');
await cs.clear();
expect(await cs.load()).toBeNull();
});
it('overwrite clears old chunks (shrinking data)', async () => {
const cs = new CookieStorage('shrink');
await cs.save('X'.repeat(10000)); // many chunks
await cs.save('small'); // one chunk
expect(await cs.load()).toBe('small');
});
});
describe('Vault', () => {
const mkData = (): AppData => ({
workEntries: [{
id: '1', date: '2024-01-01', description: 'test', amount: 100,
createdAt: 0, updatedAt: 0,
}],
payments: [],
expenses: [],
taxInputs: {},
dashboard: { charts: [], widgets: [] },
settings: { theme: 'standard', mode: 'dark', storageMode: 'cookie', defaultRate: 50 },
version: 1,
});
it('encrypts on save and decrypts on load (cookie mode)', async () => {
const v = new Vault({ mode: 'cookie', username: 'u1', password: 'supersecret' });
const data = mkData();
await v.save(data);
// Verify cookie content is NOT plaintext
expect(document.cookie).not.toContain('test');
expect(document.cookie).not.toContain('workEntries');
const loaded = await v.load();
expect(loaded).toEqual(data);
});
it('wrong password fails to load', async () => {
const v1 = new Vault({ mode: 'cookie', username: 'u2', password: 'correct' });
await v1.save(mkData());
const v2 = new Vault({ mode: 'cookie', username: 'u2', password: 'wrong' });
await expect(v2.load()).rejects.toThrow();
});
it('different users see different data', async () => {
const va = new Vault({ mode: 'cookie', username: 'alice', password: 'pw' });
const vb = new Vault({ mode: 'cookie', username: 'bob', password: 'pw' });
const dataA = mkData();
dataA.workEntries[0].description = 'alice-work';
const dataB = mkData();
dataB.workEntries[0].description = 'bob-work';
await va.save(dataA);
await vb.save(dataB);
expect((await va.load())?.workEntries[0].description).toBe('alice-work');
expect((await vb.load())?.workEntries[0].description).toBe('bob-work');
});
});

282
src/__tests__/tax.test.ts Normal file
View file

@ -0,0 +1,282 @@
import { describe, it, expect } from 'vitest';
import { calculateTax } from '@/lib/tax/calculate';
import { applyBrackets, getTaxYearData } from '@/lib/tax/brackets';
import type { Payment, Expense, TaxInputs } from '@/types';
const mkPayment = (date: string, amount: number): Payment => ({
id: 'p', date, amount, payer: 'Client', createdAt: 0, updatedAt: 0,
});
const mkExpense = (date: string, amount: number, deductible = true): Expense => ({
id: 'e', date, amount, description: 'exp', deductible, createdAt: 0, updatedAt: 0,
});
describe('applyBrackets', () => {
it('returns 0 for zero/negative income', () => {
const b = getTaxYearData(2024).brackets.single;
expect(applyBrackets(b, 0)).toBe(0);
expect(applyBrackets(b, -100)).toBe(0);
});
it('applies 10% bracket correctly (2024 single)', () => {
const b = getTaxYearData(2024).brackets.single;
expect(applyBrackets(b, 10000)).toBeCloseTo(1000, 2);
});
it('applies across multiple brackets (2024 single, $50k taxable)', () => {
const b = getTaxYearData(2024).brackets.single;
// 11600 * 0.10 + (47150 - 11600) * 0.12 + (50000 - 47150) * 0.22
const expected = 11600 * 0.10 + 35550 * 0.12 + 2850 * 0.22;
expect(applyBrackets(b, 50000)).toBeCloseTo(expected, 2);
});
it('handles top bracket (infinity)', () => {
const b = getTaxYearData(2024).brackets.single;
const tax = applyBrackets(b, 1_000_000);
expect(tax).toBeGreaterThan(300_000);
expect(Number.isFinite(tax)).toBe(true);
});
});
describe('getTaxYearData', () => {
it('returns exact year data when available', () => {
expect(getTaxYearData(2024).year).toBe(2024);
expect(getTaxYearData(2025).year).toBe(2025);
});
it('falls back to closest year for unknown years', () => {
expect(getTaxYearData(2030).year).toBe(2025);
expect(getTaxYearData(2020).year).toBe(2024);
});
it('provides 4 quarterly due dates', () => {
expect(getTaxYearData(2024).quarterlyDueDates).toHaveLength(4);
});
});
describe('calculateTax — core SE tax', () => {
const baseInputs: TaxInputs = { taxYear: 2024, filingStatus: 'single' };
it('zero income → zero tax', () => {
const r = calculateTax([], [], baseInputs);
expect(r.totalFederalTax).toBe(0);
expect(r.netProfit).toBe(0);
expect(r.totalSETax).toBe(0);
});
it('$50k net profit, single, 2024 — SE tax math', () => {
const r = calculateTax([mkPayment('2024-03-01', 50000)], [], baseInputs);
// SE base: 50000 * 0.9235 = 46175
expect(r.seTaxableBase).toBeCloseTo(46175, 2);
// SS: 46175 * 0.124 = 5725.70
expect(r.socialSecurityTax).toBeCloseTo(5725.70, 2);
// Medicare: 46175 * 0.029 = 1339.08 (rounds to 1339.08)
expect(r.medicareTax).toBeCloseTo(1339.08, 1);
// Total SE: ~7064.78
expect(r.totalSETax).toBeCloseTo(7064.78, 1);
// SE deduction = half of (SS + Medicare)
expect(r.seTaxDeduction).toBeCloseTo(3532.39, 1);
});
it('deductible expenses reduce net profit', () => {
const r = calculateTax(
[mkPayment('2024-03-01', 50000)],
[mkExpense('2024-02-01', 10000)],
baseInputs,
);
expect(r.netProfit).toBe(40000);
});
it('non-deductible expenses do NOT reduce net profit', () => {
const r = calculateTax(
[mkPayment('2024-03-01', 50000)],
[mkExpense('2024-02-01', 10000, false)],
baseInputs,
);
expect(r.netProfit).toBe(50000);
});
it('filters payments by tax year', () => {
const r = calculateTax(
[mkPayment('2023-12-31', 99999), mkPayment('2024-01-01', 1000)],
[],
baseInputs,
);
expect(r.grossReceipts).toBe(1000);
});
it('Social Security tax capped at wage base', () => {
const r = calculateTax([mkPayment('2024-01-01', 500000)], [], baseInputs);
// 500k * 0.9235 = 461750, but SS only applies to first 168600
expect(r.socialSecurityTax).toBeCloseTo(168600 * 0.124, 2);
// Medicare has no cap
expect(r.medicareTax).toBeCloseTo(461750 * 0.029, 1);
});
it('W-2 wages reduce SS room (combined cap)', () => {
const r = calculateTax(
[mkPayment('2024-01-01', 100000)],
[],
{ ...baseInputs, w2Wages: 168600 }, // already hit SS cap via W-2
);
expect(r.socialSecurityTax).toBe(0);
// Medicare still applies
expect(r.medicareTax).toBeGreaterThan(0);
});
it('Additional Medicare kicks in above threshold', () => {
const low = calculateTax([mkPayment('2024-01-01', 100000)], [], baseInputs);
expect(low.additionalMedicareTax).toBe(0);
const high = calculateTax([mkPayment('2024-01-01', 300000)], [], baseInputs);
expect(high.additionalMedicareTax).toBeGreaterThan(0);
});
it('Standard deduction applied (2024 single = $14,600)', () => {
const r = calculateTax([mkPayment('2024-01-01', 30000)], [], baseInputs);
expect(r.standardDeduction).toBe(14600);
});
it('QBI deduction reduces taxable income', () => {
const r = calculateTax([mkPayment('2024-01-01', 80000)], [], baseInputs);
expect(r.qbiDeduction).toBeGreaterThan(0);
// QBI capped at 20% of QBI
const qbiBase = r.netProfit - r.seTaxDeduction;
expect(r.qbiDeduction).toBeLessThanOrEqual(qbiBase * 0.20 + 0.01);
});
it('QBI phases out for high earners', () => {
// Way above phaseout end
const r = calculateTax([mkPayment('2024-01-01', 500000)], [], baseInputs);
expect(r.qbiDeduction).toBe(0);
});
it('Married Filing Jointly uses MFJ brackets & deduction', () => {
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], {
taxYear: 2024,
filingStatus: 'mfj',
});
expect(r.standardDeduction).toBe(29200);
});
});
describe('calculateTax — safe harbor & quarterly', () => {
const baseInputs: TaxInputs = { taxYear: 2024, filingStatus: 'single' };
it('prompts for prior year data when missing', () => {
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], baseInputs);
const fields = r.prompts.map((p) => p.field);
expect(fields).toContain('priorYearTax');
expect(fields).toContain('priorYearAGI');
});
it('no safe harbor when prior year data missing', () => {
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], baseInputs);
expect(r.safeHarborAmount).toBeNull();
expect(r.safeHarborMet).toBeNull();
});
it('computes 100% safe harbor for normal AGI', () => {
const r = calculateTax([mkPayment('2024-01-01', 100000)], [], {
...baseInputs,
priorYearTax: 8000,
priorYearAGI: 60000,
});
// Safe harbor = min(100% of prior tax, 90% of current)
// Prior: 8000 * 1.00 = 8000
// Current 90% will be higher, so safe harbor = 8000
expect(r.safeHarborAmount).toBe(8000);
});
it('computes 110% safe harbor for high prior AGI', () => {
const r = calculateTax([mkPayment('2024-01-01', 200000)], [], {
...baseInputs,
priorYearTax: 10000,
priorYearAGI: 200000, // > 150k
});
// 110% rule: 10000 * 1.10 = 11000
expect(r.safeHarborAmount).toBeLessThanOrEqual(11000);
expect(r.notes.some((n) => n.includes('110%'))).toBe(true);
});
it('safeHarborMet true when already paid enough', () => {
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], {
...baseInputs,
priorYearTax: 5000,
priorYearAGI: 40000,
estimatedPaymentsMade: 5000,
});
expect(r.safeHarborMet).toBe(true);
});
it('safeHarborMet false when underpaid', () => {
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], {
...baseInputs,
priorYearTax: 5000,
priorYearAGI: 40000,
estimatedPaymentsMade: 100,
});
expect(r.safeHarborMet).toBe(false);
});
it('produces 4 quarterly payments summing to annual liability', () => {
const r = calculateTax([mkPayment('2024-01-01', 100000)], [], baseInputs);
expect(r.quarterlySchedule).toHaveLength(4);
const sum = r.quarterlySchedule.reduce((s, q) => s + q.projectedAmount, 0);
expect(sum).toBeCloseTo(r.totalFederalTax, 0);
});
it('marks past-due quarters correctly', () => {
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], baseInputs, {
asOf: new Date('2024-07-01'),
});
// Q1 (Apr 15) and Q2 (Jun 17) should be past due
expect(r.quarterlySchedule[0].isPastDue).toBe(true);
expect(r.quarterlySchedule[1].isPastDue).toBe(true);
expect(r.quarterlySchedule[2].isPastDue).toBe(false);
expect(r.quarterlySchedule[3].isPastDue).toBe(false);
});
it('withholding reduces quarterly need', () => {
const withW2 = calculateTax([mkPayment('2024-01-01', 50000)], [], {
...baseInputs,
federalWithholding: 5000,
});
const without = calculateTax([mkPayment('2024-01-01', 50000)], [], baseInputs);
expect(withW2.quarterlySchedule[0].projectedAmount).toBeLessThan(
without.quarterlySchedule[0].projectedAmount,
);
});
it('notes <$1000 threshold exemption', () => {
const r = calculateTax([mkPayment('2024-01-01', 3000)], [], baseInputs);
expect(r.notes.some((n) => n.includes('$1,000'))).toBe(true);
});
});
describe('calculateTax — projection mode', () => {
it('scales YTD to full year when project=true', () => {
// Halfway through 2024 with $50k → project $100k
const actual = calculateTax([mkPayment('2024-01-15', 50000)], [], {
taxYear: 2024,
filingStatus: 'single',
}, {
asOf: new Date('2024-07-01'),
project: true,
});
expect(actual.grossReceipts).toBeGreaterThan(90000);
expect(actual.grossReceipts).toBeLessThan(110000);
expect(actual.notes.some((n) => n.includes('Projected'))).toBe(true);
});
it('does not project past years', () => {
const r = calculateTax([mkPayment('2024-06-01', 50000)], [], {
taxYear: 2024,
filingStatus: 'single',
}, {
asOf: new Date('2025-06-01'),
project: true,
});
expect(r.grossReceipts).toBe(50000);
});
});

View file

@ -0,0 +1,245 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useTimerStore } from '@/store/timerStore';
describe('timerStore', () => {
beforeEach(() => {
vi.useFakeTimers();
useTimerStore.setState({
currentRate: 50,
running: false,
runStartedAt: null,
accumulatedMs: 0,
splits: [],
lastHeartbeat: Date.now(),
elapsedMs: 0,
crashRecovery: null,
});
});
afterEach(() => {
vi.useRealTimers();
});
it('starts and tracks elapsed time', () => {
const s = useTimerStore.getState();
s.start();
expect(useTimerStore.getState().running).toBe(true);
vi.advanceTimersByTime(5000);
useTimerStore.getState()._tick();
expect(useTimerStore.getState().elapsedMs).toBeGreaterThanOrEqual(5000);
});
it('pause freezes elapsed and stops ticking', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(3000);
useTimerStore.getState().pause();
const frozen = useTimerStore.getState().elapsedMs;
expect(useTimerStore.getState().running).toBe(false);
vi.advanceTimersByTime(10000);
expect(useTimerStore.getState().elapsedMs).toBe(frozen);
});
it('resume after pause accumulates correctly', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(2000);
useTimerStore.getState().pause();
vi.advanceTimersByTime(5000); // paused time doesn't count
useTimerStore.getState().start();
vi.advanceTimersByTime(3000);
useTimerStore.getState()._tick();
const elapsed = useTimerStore.getState().elapsedMs;
expect(elapsed).toBeGreaterThanOrEqual(5000);
expect(elapsed).toBeLessThan(7000); // not 10000
});
it('split records current segment and resets clock', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(4000);
const split = useTimerStore.getState().split('task A');
expect(split.elapsedMs).toBeGreaterThanOrEqual(4000);
expect(split.rate).toBe(50);
expect(split.label).toBe('task A');
expect(split.recorded).toBe(false);
expect(useTimerStore.getState().splits).toHaveLength(1);
// Clock resets but keeps running
expect(useTimerStore.getState().accumulatedMs).toBe(0);
expect(useTimerStore.getState().running).toBe(true);
});
it('split preserves rate at time of split (rate change after doesn\'t affect)', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
const split = useTimerStore.getState().split();
expect(split.rate).toBe(50);
useTimerStore.getState().setRate(100);
expect(useTimerStore.getState().splits[0].rate).toBe(50);
expect(useTimerStore.getState().currentRate).toBe(100);
});
it('setRate only affects live clock, not existing splits', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
useTimerStore.getState().split();
useTimerStore.getState().split();
useTimerStore.getState().setRate(999);
expect(useTimerStore.getState().splits.every((s) => s.rate === 50)).toBe(true);
});
it('markRecorded sets flag and stores work entry id', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
const split = useTimerStore.getState().split();
useTimerStore.getState().markRecorded(split.id, 'we-123');
const updated = useTimerStore.getState().splits.find((s) => s.id === split.id)!;
expect(updated.recorded).toBe(true);
expect(updated.recordedWorkEntryId).toBe('we-123');
});
it('updateSplit modifies fields', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
const split = useTimerStore.getState().split();
useTimerStore.getState().updateSplit(split.id, { label: 'new label', elapsedMs: 9999 });
const updated = useTimerStore.getState().splits.find((s) => s.id === split.id)!;
expect(updated.label).toBe('new label');
expect(updated.elapsedMs).toBe(9999);
});
it('deleteSplit removes from table', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
const split = useTimerStore.getState().split();
useTimerStore.getState().deleteSplit(split.id);
expect(useTimerStore.getState().splits).toHaveLength(0);
});
it('reset clears everything', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
useTimerStore.getState().split();
useTimerStore.getState().reset();
expect(useTimerStore.getState().running).toBe(false);
expect(useTimerStore.getState().elapsedMs).toBe(0);
expect(useTimerStore.getState().splits).toHaveLength(0);
});
it('persists state to cookie on tick', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1500);
useTimerStore.getState()._tick();
expect(document.cookie).toContain('t99_timer');
});
});
describe('timerStore crash recovery', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('detects crash when heartbeat stale + running', () => {
const now = Date.now();
const staleSnapshot = {
currentRate: 75,
running: true,
runStartedAt: now - 60000, // started 1 min ago
accumulatedMs: 0,
splits: [],
lastHeartbeat: now - 30000, // last seen 30s ago (> 5s threshold)
};
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(staleSnapshot))}; path=/`;
useTimerStore.setState({ crashRecovery: null });
useTimerStore.getState()._restoreFromCookie();
const cr = useTimerStore.getState().crashRecovery;
expect(cr).not.toBeNull();
expect(cr!.gapMs).toBeGreaterThan(25000);
expect(cr!.crashTime).toBe(now - 30000);
});
it('does NOT flag crash when paused', () => {
const now = Date.now();
const snapshot = {
currentRate: 50,
running: false,
runStartedAt: null,
accumulatedMs: 120000,
splits: [],
lastHeartbeat: now - 60000,
};
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
useTimerStore.setState({ crashRecovery: null });
useTimerStore.getState()._restoreFromCookie();
expect(useTimerStore.getState().crashRecovery).toBeNull();
});
it('restores elapsed time from original start (includes gap)', () => {
const now = Date.now();
const snapshot = {
currentRate: 50,
running: true,
runStartedAt: now - 120000, // 2 min ago
accumulatedMs: 0,
splits: [],
lastHeartbeat: now - 60000,
};
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
useTimerStore.getState()._restoreFromCookie();
// Clock should show ~2 minutes (original start was preserved)
expect(useTimerStore.getState().elapsedMs).toBeGreaterThanOrEqual(120000);
});
it('subtractCrashGap removes gap from accumulated time', () => {
useTimerStore.setState({
running: false,
runStartedAt: null,
accumulatedMs: 100000,
crashRecovery: { crashTime: 0, reloadTime: 30000, gapMs: 30000 },
elapsedMs: 100000,
});
useTimerStore.getState().subtractCrashGap();
expect(useTimerStore.getState().accumulatedMs).toBe(70000);
expect(useTimerStore.getState().crashRecovery).toBeNull();
});
it('subtractCrashGap clamps to zero', () => {
useTimerStore.setState({
accumulatedMs: 5000,
crashRecovery: { crashTime: 0, reloadTime: 0, gapMs: 99999 },
});
useTimerStore.getState().subtractCrashGap();
expect(useTimerStore.getState().accumulatedMs).toBe(0);
});
it('dismissCrashBanner keeps time but removes banner', () => {
useTimerStore.setState({
accumulatedMs: 100000,
crashRecovery: { crashTime: 0, reloadTime: 0, gapMs: 30000 },
});
useTimerStore.getState().dismissCrashBanner();
expect(useTimerStore.getState().crashRecovery).toBeNull();
expect(useTimerStore.getState().accumulatedMs).toBe(100000); // unchanged
});
it('restores splits from cookie', () => {
const snapshot = {
currentRate: 50,
running: false,
runStartedAt: null,
accumulatedMs: 0,
splits: [
{ id: 's1', startedAt: 0, elapsedMs: 60000, rate: 50, recorded: false },
{ id: 's2', startedAt: 0, elapsedMs: 30000, rate: 75, recorded: true, recordedWorkEntryId: 'we-1' },
],
lastHeartbeat: Date.now(),
};
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
useTimerStore.getState()._restoreFromCookie();
expect(useTimerStore.getState().splits).toHaveLength(2);
expect(useTimerStore.getState().splits[1].recorded).toBe(true);
});
});

View file

@ -0,0 +1,401 @@
/**
* Configurable chart panel. Renders one ChartConfig as a Recharts chart,
* with inline controls for type, metrics, granularity, and axis ranges.
*/
import { useMemo, useState } from 'react';
import {
ResponsiveContainer,
ComposedChart,
LineChart, Line,
BarChart, Bar,
Area,
PieChart, Pie, Cell,
XAxis, YAxis, Tooltip, Legend, CartesianGrid,
} from 'recharts';
import type { ChartConfig, ChartMetric, ChartType, ChartGranularity } from '@/types';
import { buildChartSeries } from '@/lib/stats/aggregate';
import { useAppStore } from '@/store/appStore';
import { fmtMoneyShort } from '@/lib/format';
const METRIC_LABELS: Record<ChartMetric, string> = {
workValue: 'Work Value',
payments: 'Payments',
expenses: 'Expenses',
netIncome: 'Net Income',
cumulativePayments: 'Cumulative Payments',
cumulativeNet: 'Cumulative Net',
};
const CHART_COLORS = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
'var(--chart-4)', 'var(--chart-5)',
];
interface Props {
config: ChartConfig;
onChange: (patch: Partial<ChartConfig>) => void;
onRemove?: () => void;
defaultRangeStart?: string;
defaultRangeEnd?: string;
}
export function ChartPanel({ config, onChange, onRemove, defaultRangeStart, defaultRangeEnd }: Props) {
const work = useAppStore((s) => s.data.workEntries);
const payments = useAppStore((s) => s.data.payments);
const expenses = useAppStore((s) => s.data.expenses);
const [showControls, setShowControls] = useState(false);
const rawData = useMemo(
() =>
buildChartSeries(
work,
payments,
expenses,
config.metrics,
config.granularity,
config.rangeStart ?? defaultRangeStart ?? null,
config.rangeEnd ?? defaultRangeEnd ?? null,
),
[work, payments, expenses, config, defaultRangeStart, defaultRangeEnd],
);
const data = useMemo(() => {
const w = config.rollingAvgWindow;
// For day granularity: fill the full date range so every calendar day is
// present (missing days = 0). This makes the window represent actual days,
// not just data points, and includes zero-value days in the average.
let series = rawData;
if (config.granularity === 'day' && rawData.length > 0) {
const byDate = new Map(rawData.map((p) => [p.label, p]));
const startStr = (config.rangeStart ?? defaultRangeStart) ?? rawData[0].label;
const endStr = (config.rangeEnd ?? defaultRangeEnd) ?? rawData[rawData.length - 1].label;
const filled: typeof rawData = [];
const cur = new Date(startStr + 'T00:00:00');
const end = new Date(endStr + 'T00:00:00');
while (cur <= end) {
const d = cur.toISOString().slice(0, 10);
const existing = byDate.get(d);
if (existing) {
filled.push(existing);
} else {
const zero: (typeof rawData)[0] = { label: d };
for (const m of config.metrics) zero[m] = 0;
filled.push(zero);
}
cur.setDate(cur.getDate() + 1);
}
series = filled;
}
if (!w || w < 1) return series;
// Compute rolling avg; only emit once a full w-day window is available
return series.map((point, i) => {
if (i + 1 < w) return point;
const result = { ...point };
const slice = series.slice(i - w + 1, i + 1);
for (const m of config.metrics) {
const avg = slice.reduce((s, p) => s + (Number(p[m]) || 0), 0) / w;
result[`${m}_avg`] = Math.round(avg * 100) / 100;
}
return result;
});
}, [rawData, config.rollingAvgWindow, config.granularity, config.metrics,
config.rangeStart, config.rangeEnd, defaultRangeStart, defaultRangeEnd]);
const yDomain: [number | 'auto', number | 'auto'] = [
config.yMin ?? 'auto',
config.yMax ?? 'auto',
];
const toggleMetric = (m: ChartMetric) => {
const has = config.metrics.includes(m);
const next = has
? config.metrics.filter((x) => x !== m)
: [...config.metrics, m];
if (next.length > 0) onChange({ metrics: next });
};
return (
<div className="card" style={{ flex: 1, minHeight: 300, display: 'flex', flexDirection: 'column' }}>
<div className="card-header">
<input
className="input input-inline"
style={{ border: 'none', fontWeight: 600, fontSize: 14, background: 'transparent', width: '60%' }}
value={config.title ?? ''}
placeholder="Chart title"
onChange={(e) => onChange({ title: e.target.value })}
/>
<div className="flex gap-1">
<button className="btn btn-sm btn-ghost" onClick={() => setShowControls(!showControls)} title="Configure">
</button>
{onRemove && (
<button className="btn btn-sm btn-ghost text-danger" onClick={onRemove} title="Remove chart">
</button>
)}
</div>
</div>
{showControls && (
<div className="flex-col gap-2 mb-4" style={{ padding: 12, background: 'var(--bg-elev-2)', borderRadius: 'var(--radius-sm)' }}>
{/* Chart type */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted" style={{ width: 80 }}>Type</span>
<div className="btn-group">
{(['line', 'bar', 'area', 'pie'] as ChartType[]).map((t) => (
<button
key={t}
className={`btn btn-sm ${config.type === t ? 'active' : ''}`}
onClick={() => onChange({ type: t })}
>
{t}
</button>
))}
</div>
</div>
{/* Granularity */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted" style={{ width: 80 }}>Grain</span>
<div className="btn-group">
{(['day', 'week', 'month', 'year'] as ChartGranularity[]).map((g) => (
<button
key={g}
className={`btn btn-sm ${config.granularity === g ? 'active' : ''}`}
onClick={() => onChange({ granularity: g })}
>
{g}
</button>
))}
</div>
</div>
{/* Metrics */}
<div className="flex items-center gap-2" style={{ flexWrap: 'wrap' }}>
<span className="text-sm text-muted" style={{ width: 80 }}>Data</span>
{(Object.keys(METRIC_LABELS) as ChartMetric[]).map((m) => (
<label key={m} className="checkbox text-sm">
<input
type="checkbox"
checked={config.metrics.includes(m)}
onChange={() => toggleMetric(m)}
/>
{METRIC_LABELS[m]}
</label>
))}
</div>
{/* X range */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted" style={{ width: 80 }}>X range</span>
<input
type="date"
className="input input-inline"
style={{ width: 140 }}
value={config.rangeStart ?? ''}
onChange={(e) => onChange({ rangeStart: e.target.value || null })}
/>
<span className="text-muted">to</span>
<input
type="date"
className="input input-inline"
style={{ width: 140 }}
value={config.rangeEnd ?? ''}
onChange={(e) => onChange({ rangeEnd: e.target.value || null })}
/>
</div>
{/* Y range */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted" style={{ width: 80 }}>Y range</span>
<input
type="number"
className="input input-inline"
style={{ width: 100 }}
placeholder="auto"
value={config.yMin ?? ''}
onChange={(e) => onChange({ yMin: e.target.value ? Number(e.target.value) : null })}
/>
<span className="text-muted">to</span>
<input
type="number"
className="input input-inline"
style={{ width: 100 }}
placeholder="auto"
value={config.yMax ?? ''}
onChange={(e) => onChange({ yMax: e.target.value ? Number(e.target.value) : null })}
/>
</div>
{/* Rolling average */}
{(config.type === 'line' || config.type === 'area') && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted" style={{ width: 80 }}>Rolling avg</span>
<label className="checkbox text-sm">
<input
type="checkbox"
checked={!!config.rollingAvgWindow}
onChange={(e) => onChange({ rollingAvgWindow: e.target.checked ? 5 : null })}
/>
Enabled
</label>
{!!config.rollingAvgWindow && (
<>
<input
type="number"
className="input input-inline"
style={{ width: 70 }}
min={2}
max={365}
value={config.rollingAvgWindow}
onChange={(e) => onChange({ rollingAvgWindow: e.target.value ? Math.max(2, Number(e.target.value)) : null })}
/>
<span className="text-sm text-muted">periods</span>
</>
)}
</div>
)}
</div>
)}
{/* Chart render */}
<div style={{ height: 280 }}>
{data.length === 0 ? (
<div className="flex items-center justify-between full-height text-muted" style={{ justifyContent: 'center' }}>
No data to display
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
{renderChart(config, data, yDomain)}
</ResponsiveContainer>
)}
</div>
</div>
);
}
function renderChart(
config: ChartConfig,
data: ReturnType<typeof buildChartSeries>,
yDomain: [number | 'auto', number | 'auto'],
) {
const common = {
data,
margin: { top: 5, right: 10, bottom: 5, left: 0 },
};
const axes = (
<>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="label" stroke="var(--fg-muted)" fontSize={11} />
<YAxis
stroke="var(--fg-muted)"
fontSize={11}
domain={yDomain}
tickFormatter={(v) => fmtMoneyShort(v)}
/>
<Tooltip
contentStyle={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
}}
formatter={(v: number) => fmtMoneyShort(v)}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
</>
);
switch (config.type) {
case 'line':
return (
<LineChart {...common}>
{axes}
{config.metrics.map((m, i) => (
<Line
key={m}
type="monotone"
dataKey={m}
name={METRIC_LABELS[m]}
stroke={CHART_COLORS[i % CHART_COLORS.length]}
strokeWidth={2}
dot={false}
/>
))}
{config.rollingAvgWindow && config.metrics.map((m, i) => (
<Line
key={`${m}_avg`}
type="monotone"
dataKey={`${m}_avg`}
name={`${METRIC_LABELS[m]} (${config.rollingAvgWindow}d avg)`}
stroke={CHART_COLORS[i % CHART_COLORS.length]}
strokeWidth={2}
strokeDasharray="5 3"
dot={false}
/>
))}
</LineChart>
);
case 'bar':
return (
<BarChart {...common}>
{axes}
{config.metrics.map((m, i) => (
<Bar key={m} dataKey={m} name={METRIC_LABELS[m]} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</BarChart>
);
case 'area':
return (
<ComposedChart {...common}>
{axes}
{config.metrics.map((m, i) => (
<Area
key={m}
type="monotone"
dataKey={m}
name={METRIC_LABELS[m]}
stroke={CHART_COLORS[i % CHART_COLORS.length]}
fill={CHART_COLORS[i % CHART_COLORS.length]}
fillOpacity={0.3}
/>
))}
{config.rollingAvgWindow && config.metrics.map((m, i) => (
<Line
key={`${m}_avg`}
type="monotone"
dataKey={`${m}_avg`}
name={`${METRIC_LABELS[m]} (${config.rollingAvgWindow}d avg)`}
stroke={CHART_COLORS[i % CHART_COLORS.length]}
strokeWidth={2}
strokeDasharray="5 3"
dot={false}
/>
))}
</ComposedChart>
);
case 'pie': {
// Pie: sum each metric over the range
const totals = config.metrics.map((m) => ({
name: METRIC_LABELS[m],
value: data.reduce((s, d) => s + (Number(d[m]) || 0), 0),
}));
return (
<PieChart>
<Pie data={totals} dataKey="value" nameKey="name" outerRadius="80%" label>
{totals.map((_, i) => (
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v: number) => fmtMoneyShort(v)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
);
}
}
}

View file

@ -0,0 +1,66 @@
/**
* Right-side chart column used on data pages.
* Shows the user's configured charts + an "Add chart" button.
* When `tab` is provided, uses the per-ledger chart arrays instead of the
* shared dashboard charts.
*/
import { useAppStore } from '@/store/appStore';
import { ChartPanel } from './ChartPanel';
type PageTab = 'work' | 'payments' | 'expenses' | 'tax';
export function ChartSidebar({ tab, defaultRangeStart, defaultRangeEnd }: {
tab?: PageTab;
defaultRangeStart?: string;
defaultRangeEnd?: string;
}) {
const dashCharts = useAppStore((s) => s.data.dashboard.charts);
const workCharts = useAppStore((s) => s.data.dashboard.workCharts);
const paymentsCharts = useAppStore((s) => s.data.dashboard.paymentsCharts);
const expensesCharts = useAppStore((s) => s.data.dashboard.expensesCharts);
const taxCharts = useAppStore((s) => s.data.dashboard.taxCharts);
const addChart = useAppStore((s) => s.addChart);
const updateChart = useAppStore((s) => s.updateChart);
const removeChart = useAppStore((s) => s.removeChart);
const addLedgerChart = useAppStore((s) => s.addLedgerChart);
const updateLedgerChart = useAppStore((s) => s.updateLedgerChart);
const removeLedgerChart = useAppStore((s) => s.removeLedgerChart);
if (tab) {
const charts = tab === 'work' ? workCharts : tab === 'payments' ? paymentsCharts : tab === 'expenses' ? expensesCharts : taxCharts;
return (
<>
{(charts ?? []).map((c) => (
<ChartPanel
key={c.id}
config={c}
onChange={(patch) => updateLedgerChart(tab, c.id, patch)}
onRemove={(charts?.length ?? 0) > 1 ? () => removeLedgerChart(tab, c.id) : undefined}
defaultRangeStart={defaultRangeStart}
defaultRangeEnd={defaultRangeEnd}
/>
))}
<button className="btn" onClick={() => addLedgerChart(tab)}>
+ Add chart
</button>
</>
);
}
return (
<>
{dashCharts.map((c) => (
<ChartPanel
key={c.id}
config={c}
onChange={(patch) => updateChart(c.id, patch)}
onRemove={dashCharts.length > 1 ? () => removeChart(c.id) : undefined}
/>
))}
<button className="btn" onClick={() => addChart()}>
+ Add chart
</button>
</>
);
}

View file

@ -0,0 +1,72 @@
import { useEffect } from 'react';
interface Props {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
}
export function Modal({ open, title, onClose, children, footer }: Props) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3 className="modal-title">{title}</h3>
{children}
{footer && <div className="modal-footer">{footer}</div>}
</div>
</div>
);
}
/** Confirmation dialog with safe cancel default */
export function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Confirm',
danger = false,
onConfirm,
onCancel,
}: {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<Modal
open={open}
title={title}
onClose={onCancel}
footer={
<>
<button className="btn" onClick={onCancel}>
Cancel
</button>
<button
className={danger ? 'btn btn-danger' : 'btn btn-primary'}
onClick={onConfirm}
>
{confirmLabel}
</button>
</>
}
>
<p>{message}</p>
</Modal>
);
}

View file

@ -0,0 +1,97 @@
/**
* Two-panel resizable split layout.
*
* Desktop: left | drag-handle | right, with a draggable divider.
* Mobile (900px): collapsible "Charts" section above, data below.
*/
import { useState, useRef, useEffect, useCallback } from 'react';
const STORAGE_KEY = 'split_pct';
const DEFAULT_PCT = 50;
const MIN_PCT = 20;
const MAX_PCT = 80;
function clamp(v: number) { return Math.max(MIN_PCT, Math.min(MAX_PCT, v)); }
export function ResizableSplit({
left,
right,
}: {
left: React.ReactNode;
right: React.ReactNode;
}) {
const [pct, setPct] = useState(() => {
const stored = parseFloat(localStorage.getItem(STORAGE_KEY) ?? '');
return isNaN(stored) ? DEFAULT_PCT : clamp(stored);
});
const [dragging, setDragging] = useState(false);
const [chartsOpen, setChartsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const startDrag = useCallback((clientX: number) => {
setDragging(true);
// Store initial reference; actual movement handled in effect
void clientX;
}, []);
useEffect(() => {
if (!dragging) return;
const move = (clientX: number) => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const next = clamp(((clientX - rect.left) / rect.width) * 100);
setPct(next);
localStorage.setItem(STORAGE_KEY, String(Math.round(next)));
};
const onMouseMove = (e: MouseEvent) => move(e.clientX);
const onTouchMove = (e: TouchEvent) => { e.preventDefault(); move(e.touches[0].clientX); };
const stop = () => setDragging(false);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', stop);
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', stop);
return () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', stop);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', stop);
};
}, [dragging]);
return (
<div
ref={containerRef}
className={`rsplit${dragging ? ' rsplit--dragging' : ''}`}
style={{ '--split-pct': `${pct}%` } as React.CSSProperties}
>
{/* Left / data panel */}
<div className="rsplit-left">{left}</div>
{/* Drag divider — desktop only */}
<div
className="rsplit-divider"
onMouseDown={(e) => { e.preventDefault(); startDrag(e.clientX); }}
onTouchStart={(e) => { startDrag(e.touches[0].clientX); }}
>
<div className="rsplit-divider-handle" />
</div>
{/* Right / charts panel — mobile: hidden unless chartsOpen */}
<div className={`rsplit-right${chartsOpen ? ' rsplit-right--open' : ''}`}>{right}</div>
{/* Mobile-only toggle — sits below everything via CSS order */}
<button
className="rsplit-toggle"
onClick={() => setChartsOpen((o) => !o)}
>
<span>{chartsOpen ? '▲' : '▼'}</span>
Charts
</button>
</div>
);
}

View file

@ -0,0 +1,309 @@
/**
* Reusable add/edit entry form. Supports flat-amount OR hours×rate input.
*/
import { useState, useRef } from 'react';
import type { WorkEntry, Payment, Expense } from '@/types';
import { todayISO } from '@/lib/format';
// ─── Payer autocomplete ───────────────────────────────────────────────────────
function PayerInput({
value,
onChange,
suggestions,
placeholder,
required,
}: {
value: string;
onChange: (v: string) => void;
suggestions: string[];
placeholder?: string;
required?: boolean;
}) {
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const filtered = suggestions.filter(
(s) => !value || s.toLowerCase().includes(value.toLowerCase()),
);
const pick = (s: string) => {
onChange(s);
setOpen(false);
};
return (
<div className="payer-wrap">
<input
className="input"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
onFocus={() => {
if (closeTimer.current) clearTimeout(closeTimer.current);
setOpen(true);
}}
onBlur={() => {
closeTimer.current = setTimeout(() => setOpen(false), 150);
}}
required={required}
/>
{open && filtered.length > 0 && (
<div className="payer-dropdown">
{filtered.map((s) => (
<div
key={s}
className={`payer-option${s === value ? ' active' : ''}`}
onMouseDown={() => pick(s)}
>
{s}
</div>
))}
</div>
)}
</div>
);
}
// ─── Work Entry ──────────────────────────────────────────────────────────────
type WorkDraft = Omit<WorkEntry, 'id' | 'createdAt' | 'updatedAt'>;
export function WorkEntryForm({
initial,
defaultRate,
existingClients,
existingDescriptions,
onSubmit,
onCancel,
}: {
initial?: Partial<WorkDraft>;
defaultRate: number;
existingClients?: string[];
existingDescriptions?: string[];
onSubmit: (d: WorkDraft) => void;
onCancel: () => void;
}) {
const [mode, setMode] = useState<'amount' | 'time'>(
initial?.hours != null ? 'time' : 'amount',
);
const [date, setDate] = useState(initial?.date ?? todayISO());
const [desc, setDesc] = useState(initial?.description ?? '');
const [amount, setAmount] = useState(initial?.amount?.toString() ?? '');
const [hours, setHours] = useState(initial?.hours?.toString() ?? '');
const [rate, setRate] = useState(initial?.rate?.toString() ?? String(defaultRate));
const [client, setClient] = useState(initial?.client ?? '');
const computedValue =
mode === 'amount'
? parseFloat(amount) || 0
: (parseFloat(hours) || 0) * (parseFloat(rate) || 0);
const submit = (e: React.FormEvent) => {
e.preventDefault();
const base: WorkDraft = { date, description: desc, client: client || undefined };
if (mode === 'amount') base.amount = parseFloat(amount) || 0;
else {
base.hours = parseFloat(hours) || 0;
base.rate = parseFloat(rate) || 0;
}
onSubmit(base);
};
return (
<form onSubmit={submit} className="flex-col gap-3">
<div className="field-row">
<div className="field">
<label>Date</label>
<input type="date" className="input" value={date} onChange={(e) => setDate(e.target.value)} required />
</div>
<div className="field">
<label>Client</label>
<PayerInput value={client} onChange={setClient} suggestions={existingClients ?? []} placeholder="optional" />
</div>
</div>
<div className="field">
<label>Description</label>
<PayerInput value={desc} onChange={setDesc} suggestions={existingDescriptions ?? []} placeholder="What did you work on?" />
</div>
<div className="btn-group">
<button type="button" className={`btn btn-sm ${mode === 'amount' ? 'active' : ''}`} onClick={() => setMode('amount')}>
$ Flat amount
</button>
<button type="button" className={`btn btn-sm ${mode === 'time' ? 'active' : ''}`} onClick={() => setMode('time')}>
Time × rate
</button>
</div>
{mode === 'amount' ? (
<div className="field">
<label>Amount ($)</label>
<input type="number" step="0.01" className="input" value={amount} onChange={(e) => setAmount(e.target.value)} required />
</div>
) : (
<div className="field-row">
<div className="field">
<label>Hours</label>
<input type="number" step="0.01" className="input" value={hours} onChange={(e) => setHours(e.target.value)} required />
</div>
<div className="field">
<label>Rate ($/hr)</label>
<input type="number" step="0.01" className="input" value={rate} onChange={(e) => setRate(e.target.value)} required />
</div>
</div>
)}
<div className="text-muted text-sm">Value: <span className="mono">${computedValue.toFixed(2)}</span></div>
<div className="modal-footer">
<button type="button" className="btn" onClick={onCancel}>Cancel</button>
<button type="submit" className="btn btn-primary">Save</button>
</div>
</form>
);
}
// ─── Payment ─────────────────────────────────────────────────────────────────
type PaymentDraft = Omit<Payment, 'id' | 'createdAt' | 'updatedAt'>;
export function PaymentForm({
initial,
defaultForm,
existingPayers,
onSubmit,
onCancel,
}: {
initial?: Partial<PaymentDraft>;
/** Most recently used form type — used when adding a new payment */
defaultForm?: Payment['form'];
/** Existing payer names for autocomplete suggestions */
existingPayers?: string[];
onSubmit: (d: PaymentDraft) => void;
onCancel: () => void;
}) {
const [date, setDate] = useState(initial?.date ?? todayISO());
const [amount, setAmount] = useState(initial?.amount?.toString() ?? '');
const [payer, setPayer] = useState(initial?.payer ?? '');
const [form, setForm] = useState<Payment['form']>(initial?.form ?? defaultForm ?? '1099-NEC');
const [notes, setNotes] = useState(initial?.notes ?? '');
const submit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({ date, amount: parseFloat(amount) || 0, payer, form, notes: notes || undefined });
};
return (
<form onSubmit={submit} className="flex-col gap-3">
<div className="field-row">
<div className="field">
<label>Date received</label>
<input type="date" className="input" value={date} onChange={(e) => setDate(e.target.value)} required />
</div>
<div className="field">
<label>Amount ($)</label>
<input type="number" step="0.01" className="input" value={amount} onChange={(e) => setAmount(e.target.value)} required />
</div>
</div>
<div className="field-row">
<div className="field">
<label>Payer</label>
<PayerInput
required
value={payer}
onChange={setPayer}
suggestions={existingPayers ?? []}
/>
</div>
<div className="field">
<label>Form</label>
<select className="select" value={form} onChange={(e) => setForm(e.target.value as Payment['form'])}>
<option value="1099-NEC">1099-NEC</option>
<option value="1099-K">1099-K</option>
<option value="1099-MISC">1099-MISC</option>
<option value="direct">Direct / no form</option>
<option value="other">Other</option>
</select>
</div>
</div>
<div className="field">
<label>Notes</label>
<input className="input" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={onCancel}>Cancel</button>
<button type="submit" className="btn btn-primary">Save</button>
</div>
</form>
);
}
// ─── Expense ─────────────────────────────────────────────────────────────────
type ExpenseDraft = Omit<Expense, 'id' | 'createdAt' | 'updatedAt'>;
export function ExpenseForm({
initial,
onSubmit,
onCancel,
}: {
initial?: Partial<ExpenseDraft>;
onSubmit: (d: ExpenseDraft) => void;
onCancel: () => void;
}) {
const [date, setDate] = useState(initial?.date ?? todayISO());
const [amount, setAmount] = useState(initial?.amount?.toString() ?? '');
const [desc, setDesc] = useState(initial?.description ?? '');
const [deductible, setDeductible] = useState(initial?.deductible ?? true);
const [category, setCategory] = useState(initial?.category ?? '');
const submit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
date,
amount: parseFloat(amount) || 0,
description: desc,
deductible,
category: category || undefined,
});
};
return (
<form onSubmit={submit} className="flex-col gap-3">
<div className="field-row">
<div className="field">
<label>Date</label>
<input type="date" className="input" value={date} onChange={(e) => setDate(e.target.value)} required />
</div>
<div className="field">
<label>Amount ($)</label>
<input type="number" step="0.01" className="input" value={amount} onChange={(e) => setAmount(e.target.value)} required />
</div>
</div>
<div className="field">
<label>Description</label>
<input className="input" value={desc} onChange={(e) => setDesc(e.target.value)} required />
</div>
<div className="field-row">
<div className="field">
<label>Category</label>
<input className="input" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="optional" />
</div>
<div className="field">
<label>&nbsp;</label>
<label className="checkbox">
<input type="checkbox" checked={deductible} onChange={(e) => setDeductible(e.target.checked)} />
Tax deductible
</label>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={onCancel}>Cancel</button>
<button type="submit" className="btn btn-primary">Save</button>
</div>
</form>
);
}

View file

@ -0,0 +1,263 @@
/**
* Hierarchical expandable spreadsheet.
* Year Month Day Item, with per-row toggles AND global
* "expand to level" buttons.
*/
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import clsx from 'clsx';
import type { HierNode } from '@/types';
import { fmtMoney } from '@/lib/format';
export type ExpandLevel = 'year' | 'month' | 'day' | 'item';
interface Props {
nodes: HierNode[];
/** Called when user clicks an item-level row (to view/edit) */
onView?: (node: HierNode) => void;
/** Called when user clicks an item-level row's edit/delete */
onEdit?: (node: HierNode) => void;
onDelete?: (node: HierNode) => void;
/** Called when user clicks "+" on a day row; receives the ISO date string */
onAddForDay?: (date: string) => void;
/** Label for the value column (e.g. "Earned", "Paid", "Spent") */
valueLabel: string;
}
const LEVEL_ORDER: Record<ExpandLevel, number> = {
year: 0,
month: 1,
day: 2,
item: 3,
};
/** Add keys for all nodes in the subtree whose level is shallower than maxLevel. */
function expandSubtree(node: HierNode, maxLevel: ExpandLevel, keys: Set<string>) {
if (LEVEL_ORDER[node.level] < LEVEL_ORDER[maxLevel]) {
keys.add(node.key);
for (const child of node.children) expandSubtree(child, maxLevel, keys);
}
}
/** Returns the ancestor keys of `target`, or null if not found. */
function findAncestorKeys(ns: HierNode[], target: string, path: string[] = []): string[] | null {
for (const n of ns) {
if (n.key === target) return path;
const found = findAncestorKeys(n.children, target, [...path, n.key]);
if (found !== null) return found;
}
return null;
}
function filterEmpty(nodes: HierNode[]): HierNode[] {
return nodes
.filter((n) => n.value !== 0)
.map((n) => ({ ...n, children: filterEmpty(n.children) }));
}
export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay, valueLabel }: Props) {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [hideEmpty, setHideEmpty] = useState(false);
const [selectedLevel, setSelectedLevel] = useState<ExpandLevel>('year');
const [scrollToKey, setScrollToKey] = useState<string | null>(null);
const rowRefs = useRef<Map<string, HTMLTableRowElement>>(new Map());
const visibleNodes = useMemo(
() => (hideEmpty ? filterEmpty(nodes) : nodes),
[nodes, hideEmpty],
);
/** Arrow click — toggle just this node. */
const toggle = useCallback((key: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
/** Level-button click — set selected level and apply globally. */
const selectLevel = useCallback(
(level: ExpandLevel) => {
setSelectedLevel(level);
if (level === 'year') {
setExpanded(new Set());
} else {
const keys = new Set<string>();
for (const n of visibleNodes) expandSubtree(n, level, keys);
setExpanded(keys);
}
},
[visibleNodes],
);
/**
* Label click focus: collapse everything, keep ancestors visible, then
* expand this node's subtree to selectedLevel.
*/
const handleLabelClick = useCallback((node: HierNode) => {
const ancestors = findAncestorKeys(visibleNodes, node.key) ?? [];
const keys = new Set<string>(ancestors);
expandSubtree(node, selectedLevel, keys);
setExpanded(keys);
setScrollToKey(node.key);
}, [visibleNodes, selectedLevel]);
// Scroll focused row to ~1/3 from top after re-render.
useEffect(() => {
if (!scrollToKey) return;
const el = rowRefs.current.get(scrollToKey);
if (!el) return;
const vh = window.innerHeight;
const rect = el.getBoundingClientRect();
if (rect.top >= vh * 0.1 && rect.top <= vh * 0.5) { setScrollToKey(null); return; }
window.scrollTo({ top: Math.max(0, window.scrollY + rect.top - vh / 3), behavior: 'smooth' });
setScrollToKey(null);
}, [scrollToKey]);
// Flatten to visible rows
const rows = useMemo(() => {
const out: Array<{ node: HierNode; depth: number }> = [];
const walk = (ns: HierNode[], depth: number) => {
for (const n of ns) {
out.push({ node: n, depth });
if (expanded.has(n.key)) walk(n.children, depth + 1);
}
};
walk(visibleNodes, 0);
return out;
}, [visibleNodes, expanded]);
const grandTotal = useMemo(
() => nodes.reduce((s, n) => s + n.value, 0),
[nodes],
);
return (
<div className="flex-col gap-2">
{/* Expand level controls */}
<div className="flex items-center justify-between">
<div className="btn-group">
{(['year', 'month', 'day', 'item'] as ExpandLevel[]).map((lvl) => (
<button
key={lvl}
className={`btn btn-sm${selectedLevel === lvl ? ' btn-primary' : ''}`}
onClick={() => selectLevel(lvl)}
>
{lvl.charAt(0).toUpperCase() + lvl.slice(1)}
</button>
))}
</div>
<div className="flex items-center gap-2">
<button
className={`btn btn-sm${hideEmpty ? ' btn-primary' : ''}`}
onClick={() => setHideEmpty((v) => !v)}
title={hideEmpty ? 'Show all rows' : 'Hide empty rows'}
>
{hideEmpty ? 'Data only' : 'All rows'}
</button>
<span className="text-muted text-sm">{rows.length} rows</span>
</div>
</div>
{/* Table */}
<div>
<table className="data-table">
<thead>
<tr>
<th>Period / Item</th>
<th className="num">{valueLabel}</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={3} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
No entries yet
</td>
</tr>
)}
{rows.map(({ node, depth }) => {
const isExpanded = expanded.has(node.key);
const hasChildren = node.children.length > 0;
const isItem = node.level === 'item';
return (
<tr
key={node.key}
ref={(el) => { el ? rowRefs.current.set(node.key, el) : rowRefs.current.delete(node.key); }}
className={clsx('hier-row', `level-${node.level}`, isItem && onView && 'hier-row--clickable')}
onClick={isItem && onView ? () => onView(node) : undefined}
>
<td>
<div className="hier-cell">
{Array.from({ length: depth }, (_, i) => (
<span
key={i}
className={`hier-indent${i === depth - 1 ? ' branch' : ''}`}
/>
))}
{hasChildren && (
<span
className={clsx('hier-toggle', isExpanded && 'expanded')}
onClick={() => toggle(node.key)}
>
</span>
)}
{!hasChildren && <span className="hier-toggle" />}
{hasChildren
? <span className="hier-label" onClick={() => handleLabelClick(node)}>{node.label}</span>
: node.label
}
</div>
</td>
<td className="num">{fmtMoney(node.value)}</td>
<td className="num" onClick={(e) => e.stopPropagation()}>
<div className="flex gap-1">
{node.level === 'day' && onAddForDay && (
<button
className="btn btn-sm btn-ghost hier-add-btn"
onClick={() => onAddForDay(node.key)}
title={`Add entry for ${node.label}`}
>
+
</button>
)}
{isItem && onEdit && (
<button
className="btn btn-sm btn-ghost"
onClick={() => onEdit(node)}
title="Edit"
>
</button>
)}
{isItem && onDelete && (
<button
className="btn btn-sm btn-ghost text-danger"
onClick={() => onDelete(node)}
title="Delete"
>
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td>Grand Total</td>
<td className="num">{fmtMoney(grandTotal)}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,469 @@
/**
* Inline-editable spreadsheet for Work and Payment logs.
* Click any cell to edit it in place. Date range filter at top.
*/
import { useState } from 'react';
import type { WorkEntry, Payment } from '@/types';
import { workEntryValue } from '@/types';
import { fmtMoney, todayISO } from '@/lib/format';
import { ConfirmDialog } from '@/components/common/Modal';
function yearStart(): string {
return `${new Date().getFullYear()}-01-01`;
}
// ─── Shared cell primitives ───────────────────────────────────────────────────
function ECell({
value,
type = 'text',
display,
placeholder = '—',
right,
mono,
onCommit,
}: {
value: string;
type?: 'text' | 'number' | 'date';
display?: string;
placeholder?: string;
right?: boolean;
mono?: boolean;
onCommit: (v: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const start = () => { setDraft(value); setEditing(true); };
const commit = () => {
setEditing(false);
if (draft !== value) onCommit(draft);
};
if (editing) {
return (
<td style={{ padding: 0 }}>
<input
autoFocus
type={type}
step={type === 'number' ? '0.01' : undefined}
value={draft}
className="ss-input"
style={{ textAlign: right ? 'right' : undefined }}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') setEditing(false);
}}
/>
</td>
);
}
const shown = display ?? value;
return (
<td
className={`ss-cell${right ? ' ss-right' : ''}${mono ? ' mono' : ''}`}
onClick={start}
title="Click to edit"
>
{shown || <span className="ss-empty">{placeholder}</span>}
</td>
);
}
const FORM_OPTIONS: { value: string; label: string }[] = [
{ value: '1099-NEC', label: '1099-NEC' },
{ value: '1099-K', label: '1099-K' },
{ value: '1099-MISC', label: '1099-MISC' },
{ value: 'direct', label: 'Direct' },
{ value: 'other', label: 'Other' },
];
function SCell({
value,
options,
onCommit,
}: {
value: string;
options: { value: string; label: string }[];
onCommit: (v: string) => void;
}) {
const [editing, setEditing] = useState(false);
if (editing) {
return (
<td style={{ padding: 0 }}>
<select
autoFocus
value={value}
className="ss-input"
onChange={(e) => { onCommit(e.target.value); setEditing(false); }}
onBlur={() => setEditing(false)}
>
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</td>
);
}
const label = options.find((o) => o.value === value)?.label ?? value;
return (
<td className="ss-cell" onClick={() => setEditing(true)} title="Click to edit">
{label || <span className="ss-empty"></span>}
</td>
);
}
function RangeBar({
startDate,
onChange,
count,
total,
}: {
startDate: string;
onChange: (d: string) => void;
count: number;
total: number;
}) {
return (
<div className="ss-rangebar">
<label>From</label>
<input
type="date"
className="input ss-rangebar-input"
value={startDate}
onChange={(e) => onChange(e.target.value)}
/>
<span className="text-muted">to today</span>
<span className="ss-rangebar-summary">{count} {count === 1 ? 'row' : 'rows'} · {fmtMoney(total)}</span>
</div>
);
}
// ─── Work Sheet ───────────────────────────────────────────────────────────────
type WorkDraft = {
date: string; description: string; client: string;
hours: string; rate: string; amount: string;
};
function blankWork(today: string, defaultRate: number): WorkDraft {
return { date: today, description: '', client: '', hours: '', rate: String(defaultRate), amount: '' };
}
export function WorkSheet({
entries,
defaultRate,
onAdd,
onUpdate,
onDelete,
}: {
entries: WorkEntry[];
defaultRate: number;
onAdd: (e: Omit<WorkEntry, 'id' | 'createdAt' | 'updatedAt'>) => void;
onUpdate: (id: string, patch: Partial<WorkEntry>) => void;
onDelete: (id: string) => void;
}) {
const today = todayISO();
const [startDate, setStartDate] = useState(yearStart);
const [draft, setDraft] = useState(() => blankWork(today, defaultRate));
const [deleting, setDeleting] = useState<string | null>(null);
const filtered = entries
.filter((e) => e.date >= startDate && e.date <= today)
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt - a.createdAt);
const total = filtered.reduce((s, e) => s + workEntryValue(e), 0);
const draftValue = parseFloat(draft.amount) > 0
? parseFloat(draft.amount)
: (parseFloat(draft.hours) || 0) * (parseFloat(draft.rate) || 0);
const addRow = () => {
if (!draft.description.trim()) return;
const hours = parseFloat(draft.hours) || undefined;
const rate = parseFloat(draft.rate) || undefined;
const amount = parseFloat(draft.amount) || undefined;
onAdd({
date: draft.date,
description: draft.description.trim(),
client: draft.client.trim() || undefined,
hours,
rate: hours != null ? rate : undefined,
amount,
});
setDraft(blankWork(today, defaultRate));
};
return (
<div className="flex-col gap-2" style={{ flex: 1, minHeight: 0 }}>
<RangeBar startDate={startDate} onChange={setStartDate} count={filtered.length} total={total} />
<div className="ss-scroll">
<table className="ss-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Client</th>
<th className="ss-right">Hours</th>
<th className="ss-right">$/hr</th>
<th className="ss-right">Flat $</th>
<th className="ss-right">Value</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{/* Draft / add row at top */}
<tr className="ss-draft">
<td style={{ padding: 0 }}>
<input type="date" className="ss-input" value={draft.date}
onChange={(e) => setDraft({ ...draft, date: e.target.value })} />
</td>
<td style={{ padding: 0 }}>
<input className="ss-input" placeholder="Description…" value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') addRow(); }} />
</td>
<td style={{ padding: 0 }}>
<input className="ss-input" placeholder="Client" value={draft.client}
onChange={(e) => setDraft({ ...draft, client: e.target.value })} />
</td>
<td style={{ padding: 0 }}>
<input type="number" className="ss-input ss-right" placeholder="hrs" value={draft.hours}
onChange={(e) => setDraft({ ...draft, hours: e.target.value })} />
</td>
<td style={{ padding: 0 }}>
<input type="number" className="ss-input ss-right" placeholder="rate" value={draft.rate}
onChange={(e) => setDraft({ ...draft, rate: e.target.value })} />
</td>
<td style={{ padding: 0 }}>
<input type="number" className="ss-input ss-right" placeholder="flat $" value={draft.amount}
onChange={(e) => setDraft({ ...draft, amount: e.target.value })} />
</td>
<td className="ss-right mono ss-value text-muted">
{draftValue > 0 ? fmtMoney(draftValue) : '—'}
</td>
<td className="ss-actions">
<button className="btn btn-sm btn-primary" onClick={addRow}
disabled={!draft.description.trim()} title="Add row">
+
</button>
</td>
</tr>
{filtered.length === 0 && (
<tr>
<td colSpan={8} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
No entries in this period
</td>
</tr>
)}
{filtered.map((e) => {
const val = workEntryValue(e);
return (
<tr key={e.id} className="ss-row">
<ECell value={e.date} type="date"
onCommit={(v) => onUpdate(e.id, { date: v })} />
<ECell value={e.description}
onCommit={(v) => onUpdate(e.id, { description: v })} />
<ECell value={e.client ?? ''} placeholder="—"
onCommit={(v) => onUpdate(e.id, { client: v.trim() || undefined })} />
<ECell value={e.hours?.toString() ?? ''} type="number" right placeholder="—"
onCommit={(v) => {
const hours = parseFloat(v) || undefined;
onUpdate(e.id, { hours, amount: hours != null ? undefined : e.amount });
}} />
<ECell value={e.rate?.toString() ?? ''} type="number" right placeholder="—"
onCommit={(v) => onUpdate(e.id, { rate: parseFloat(v) || undefined })} />
<ECell value={e.amount?.toString() ?? ''} type="number" right placeholder="—"
onCommit={(v) => {
const amount = parseFloat(v) || undefined;
onUpdate(e.id, {
amount,
hours: amount != null ? undefined : e.hours,
rate: amount != null ? undefined : e.rate,
});
}} />
<td className="ss-right mono ss-value">{fmtMoney(val)}</td>
<td className="ss-actions">
<button className="btn btn-sm btn-ghost text-danger"
onClick={() => setDeleting(e.id)} title="Delete">
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="ss-total">
<td colSpan={6}>Total</td>
<td className="ss-right mono">{fmtMoney(total)}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<ConfirmDialog
open={deleting != null}
title="Delete work entry?"
message="This cannot be undone."
confirmLabel="Delete"
danger
onConfirm={() => { if (deleting) onDelete(deleting); setDeleting(null); }}
onCancel={() => setDeleting(null)}
/>
</div>
);
}
// ─── Payment Sheet ────────────────────────────────────────────────────────────
type PaymentDraft = { date: string; payer: string; amount: string; form: string; notes: string };
function blankPayment(today: string): PaymentDraft {
return { date: today, payer: '', amount: '', form: '1099-NEC', notes: '' };
}
export function PaymentSheet({
payments,
onAdd,
onUpdate,
onDelete,
}: {
payments: Payment[];
onAdd: (p: Omit<Payment, 'id' | 'createdAt' | 'updatedAt'>) => void;
onUpdate: (id: string, patch: Partial<Payment>) => void;
onDelete: (id: string) => void;
}) {
const today = todayISO();
const [startDate, setStartDate] = useState(yearStart);
const [draft, setDraft] = useState(() => blankPayment(today));
const [deleting, setDeleting] = useState<string | null>(null);
const filtered = payments
.filter((p) => p.date >= startDate && p.date <= today)
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt - a.createdAt);
const total = filtered.reduce((s, p) => s + p.amount, 0);
const addRow = () => {
const amount = parseFloat(draft.amount);
if (!draft.payer.trim() || !amount) return;
onAdd({
date: draft.date,
payer: draft.payer.trim(),
amount,
form: draft.form as Payment['form'],
notes: draft.notes.trim() || undefined,
});
setDraft(blankPayment(today));
};
return (
<div className="flex-col gap-2" style={{ flex: 1, minHeight: 0 }}>
<RangeBar startDate={startDate} onChange={setStartDate} count={filtered.length} total={total} />
<div className="ss-scroll">
<table className="ss-table">
<thead>
<tr>
<th>Date</th>
<th>Payer</th>
<th className="ss-right">Amount</th>
<th>Form</th>
<th>Notes</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{/* Draft / add row */}
<tr className="ss-draft">
<td style={{ padding: 0 }}>
<input type="date" className="ss-input" value={draft.date}
onChange={(e) => setDraft({ ...draft, date: e.target.value })} />
</td>
<td style={{ padding: 0 }}>
<input className="ss-input" placeholder="Payer…" value={draft.payer}
onChange={(e) => setDraft({ ...draft, payer: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') addRow(); }} />
</td>
<td style={{ padding: 0 }}>
<input type="number" step="0.01" className="ss-input ss-right" placeholder="0.00"
value={draft.amount}
onChange={(e) => setDraft({ ...draft, amount: e.target.value })} />
</td>
<td style={{ padding: 0 }}>
<select className="ss-input" value={draft.form}
onChange={(e) => setDraft({ ...draft, form: e.target.value })}>
{FORM_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</td>
<td style={{ padding: 0 }}>
<input className="ss-input" placeholder="Notes" value={draft.notes}
onChange={(e) => setDraft({ ...draft, notes: e.target.value })} />
</td>
<td className="ss-actions">
<button className="btn btn-sm btn-primary" onClick={addRow}
disabled={!draft.payer.trim() || !parseFloat(draft.amount)} title="Add row">
+
</button>
</td>
</tr>
{filtered.length === 0 && (
<tr>
<td colSpan={6} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
No payments in this period
</td>
</tr>
)}
{filtered.map((p) => (
<tr key={p.id} className="ss-row">
<ECell value={p.date} type="date"
onCommit={(v) => onUpdate(p.id, { date: v })} />
<ECell value={p.payer}
onCommit={(v) => onUpdate(p.id, { payer: v })} />
<ECell value={p.amount.toString()} type="number"
display={fmtMoney(p.amount)} right mono
onCommit={(v) => onUpdate(p.id, { amount: parseFloat(v) || 0 })} />
<SCell value={p.form ?? 'direct'} options={FORM_OPTIONS}
onCommit={(v) => onUpdate(p.id, { form: v as Payment['form'] })} />
<ECell value={p.notes ?? ''} placeholder="—"
onCommit={(v) => onUpdate(p.id, { notes: v.trim() || undefined })} />
<td className="ss-actions">
<button className="btn btn-sm btn-ghost text-danger"
onClick={() => setDeleting(p.id)} title="Delete">
</button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="ss-total">
<td colSpan={4}>Total</td>
<td className="ss-right mono">{fmtMoney(total)}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<ConfirmDialog
open={deleting != null}
title="Delete payment?"
message="This cannot be undone."
confirmLabel="Delete"
danger
onConfirm={() => { if (deleting) onDelete(deleting); setDeleting(null); }}
onCancel={() => setDeleting(null)}
/>
</div>
);
}

View file

@ -0,0 +1,121 @@
/**
* Client-side encryption using the Web Crypto API.
*
* - Password PBKDF2 (SHA-256, 100k iterations) AES-256-GCM key
* - Output: base64(salt || iv || ciphertext)
*
* This key is derived in-browser and NEVER leaves the client, whether
* storing to cookies, file download, or the cloud blob store.
*/
const PBKDF2_ITERATIONS = 100_000;
const SALT_LEN = 16;
const IV_LEN = 12;
/**
* Derive an AES-GCM key from a password + salt.
* Exported so we can cache the CryptoKey after login and avoid re-deriving
* on every save (PBKDF2 is intentionally slow).
*/
export async function deriveKey(
password: string,
salt: Uint8Array,
): Promise<CryptoKey> {
const enc = new TextEncoder();
const baseKey = await crypto.subtle.importKey(
'raw',
enc.encode(password),
'PBKDF2',
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt as BufferSource,
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256',
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
/** Encrypt a string with a password. Returns base64(salt||iv||ct). */
export async function encrypt(
plaintext: string,
password: string,
): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
const key = await deriveKey(password, salt);
const enc = new TextEncoder();
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv as BufferSource },
key,
enc.encode(plaintext),
);
return b64encode(concat(salt, iv, new Uint8Array(ct)));
}
/** Decrypt base64(salt||iv||ct). Throws if password is wrong (GCM auth fail). */
export async function decrypt(
ciphertext: string,
password: string,
): Promise<string> {
const buf = b64decode(ciphertext);
const salt = buf.slice(0, SALT_LEN);
const iv = buf.slice(SALT_LEN, SALT_LEN + IV_LEN);
const ct = buf.slice(SALT_LEN + IV_LEN);
const key = await deriveKey(password, salt);
const pt = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv as BufferSource },
key,
ct as BufferSource,
);
return new TextDecoder().decode(pt);
}
/**
* Verify a password can decrypt a given ciphertext without throwing.
* Used on the login screen.
*/
export async function verifyPassword(
ciphertext: string,
password: string,
): Promise<boolean> {
try {
await decrypt(ciphertext, password);
return true;
} catch {
return false;
}
}
// ─── bytes helpers ───────────────────────────────────────────────────────────
function concat(...arrs: Uint8Array[]): Uint8Array {
const len = arrs.reduce((s, a) => s + a.length, 0);
const out = new Uint8Array(len);
let off = 0;
for (const a of arrs) {
out.set(a, off);
off += a.length;
}
return out;
}
function b64encode(buf: Uint8Array): string {
let s = '';
for (let i = 0; i < buf.length; i++) s += String.fromCharCode(buf[i]);
return btoa(s);
}
function b64decode(s: string): Uint8Array {
const bin = atob(s);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf;
}

60
src/lib/format.ts Normal file
View file

@ -0,0 +1,60 @@
/** Formatting helpers */
export function fmtMoney(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return '—';
return n.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export function fmtMoneyShort(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return '—';
return n.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
/** Format milliseconds as H:MM:SS */
export function fmtDuration(ms: number): string {
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
/** Format milliseconds as Hh Mm Ss (verbose) */
export function fmtDurationVerbose(ms: number): string {
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
return `${h}h ${m}m ${s}s`;
}
/** Total minutes from milliseconds, ignoring leftover seconds (h*60 + m) */
export function totalMinutes(ms: number): number {
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
return h * 60 + m;
}
/** Milliseconds → decimal hours */
export function msToHours(ms: number): number {
return ms / 3_600_000;
}
export function fmtPct(n: number): string {
return (n * 100).toFixed(1) + '%';
}
export function todayISO(): string {
return new Date().toISOString().slice(0, 10);
}

6
src/lib/id.ts Normal file
View file

@ -0,0 +1,6 @@
/** Tiny collision-resistant id generator (FOSS-friendly, no deps) */
export function uid(): string {
const t = Date.now().toString(36);
const r = Math.random().toString(36).slice(2, 10);
return `${t}-${r}`;
}

377
src/lib/stats/aggregate.ts Normal file
View file

@ -0,0 +1,377 @@
/**
* Statistics & Projection Engine
*
* Aggregates work entries, payments, and expenses at year/month/day levels,
* computes averages per child period, and projects end-of-period totals
* from run rates.
*/
import type {
WorkEntry,
Payment,
Expense,
PeriodStats,
HierNode,
ChartMetric,
ChartGranularity,
ISODate,
} from '@/types';
import { workEntryValue } from '@/types';
import {
getDaysInMonth,
format,
startOfWeek,
addDays,
} from 'date-fns';
interface DatedValue {
date: ISODate;
value: number;
}
// ─── Core aggregation ────────────────────────────────────────────────────────
export interface Aggregates {
years: PeriodStats[];
months: Map<string, PeriodStats>; // key: YYYY-MM
days: Map<string, PeriodStats>; // key: YYYY-MM-DD
}
export function aggregate(
work: WorkEntry[],
payments: Payment[],
expenses: Expense[],
asOf: Date = new Date(),
): Aggregates {
// Index everything by day first
const days = new Map<string, PeriodStats>();
const touch = (date: ISODate) => {
if (!days.has(date)) {
days.set(date, blank(date));
}
return days.get(date)!;
};
for (const w of work) {
touch(w.date).workValue += workEntryValue(w);
}
for (const p of payments) {
touch(p.date).payments += p.amount;
}
for (const e of expenses) {
const d = touch(e.date);
d.expenses += e.amount;
if (e.deductible) d.deductibleExpenses += e.amount;
}
for (const d of days.values()) {
d.net = d.payments - d.expenses;
d.childCount = 1; // days have 1 implicit child (themselves) for avg purposes
}
// Roll up to months
const months = new Map<string, PeriodStats>();
for (const [date, d] of days) {
const mk = date.slice(0, 7);
if (!months.has(mk)) months.set(mk, blank(mk));
const m = months.get(mk)!;
m.workValue += d.workValue;
m.payments += d.payments;
m.expenses += d.expenses;
m.deductibleExpenses += d.deductibleExpenses;
m.childCount += 1;
}
for (const [mk, m] of months) {
m.net = m.payments - m.expenses;
m.avgPerChild = m.childCount > 0 ? m.net / m.childCount : null;
// Projection: if this is the current month, scale by days remaining
const [y, mo] = mk.split('-').map(Number);
if (y === asOf.getFullYear() && mo === asOf.getMonth() + 1) {
const daysInMonth = getDaysInMonth(new Date(y, mo - 1));
const dayOfMonth = asOf.getDate();
if (dayOfMonth < daysInMonth && dayOfMonth > 0) {
m.projected = (m.net / dayOfMonth) * daysInMonth;
}
}
}
// Roll up to years
const yearMap = new Map<string, PeriodStats>();
for (const [mk, m] of months) {
const yk = mk.slice(0, 4);
if (!yearMap.has(yk)) yearMap.set(yk, blank(yk));
const y = yearMap.get(yk)!;
y.workValue += m.workValue;
y.payments += m.payments;
y.expenses += m.expenses;
y.deductibleExpenses += m.deductibleExpenses;
y.childCount += 1;
}
for (const [yk, y] of yearMap) {
y.net = y.payments - y.expenses;
y.avgPerChild = y.childCount > 0 ? y.net / y.childCount : null;
// Projection for current year
const yr = Number(yk);
if (yr === asOf.getFullYear()) {
const frac = fractionOfYearElapsed(yr, asOf);
if (frac > 0 && frac < 1) y.projected = y.net / frac;
}
}
const years = [...yearMap.values()].sort((a, b) =>
b.label.localeCompare(a.label),
);
return { years, months, days };
}
function blank(label: string): PeriodStats {
return {
label,
workValue: 0,
payments: 0,
expenses: 0,
deductibleExpenses: 0,
net: 0,
avgPerChild: null,
projected: null,
childCount: 0,
};
}
function fractionOfYearElapsed(year: number, asOf: Date): number {
const start = new Date(year, 0, 1).getTime();
const end = new Date(year + 1, 0, 1).getTime();
const now = Math.min(Math.max(asOf.getTime(), start), end);
return (now - start) / (end - start);
}
// ─── Hierarchical tree builder (for the expandable spreadsheet) ──────────────
export function buildHierarchy(
entries: Array<WorkEntry | Payment | Expense>,
valueOf: (e: WorkEntry | Payment | Expense) => number,
labelOf: (e: WorkEntry | Payment | Expense) => string,
): HierNode[] {
// Group by year > month > day > item
const byYear = new Map<string, Map<string, Map<string, Array<WorkEntry | Payment | Expense>>>>();
for (const e of entries) {
const y = e.date.slice(0, 4);
const m = e.date.slice(0, 7);
const d = e.date;
if (!byYear.has(y)) byYear.set(y, new Map());
const ym = byYear.get(y)!;
if (!ym.has(m)) ym.set(m, new Map());
const md = ym.get(m)!;
if (!md.has(d)) md.set(d, []);
md.get(d)!.push(e);
}
const years: HierNode[] = [];
for (const [y, monthMap] of [...byYear].sort((a, b) => b[0].localeCompare(a[0]))) {
const months: HierNode[] = [];
let yearTotal = 0;
for (const [m, dayMap] of [...monthMap].sort((a, b) => b[0].localeCompare(a[0]))) {
const days: HierNode[] = [];
let monthTotal = 0;
for (const [d, items] of [...dayMap].sort((a, b) => b[0].localeCompare(a[0]))) {
const itemNodes: HierNode[] = items.map((e) => ({
key: e.id,
level: 'item' as const,
label: labelOf(e),
value: valueOf(e),
children: [],
entry: e,
}));
const dayTotal = itemNodes.reduce((s, n) => s + n.value, 0);
monthTotal += dayTotal;
days.push({
key: d,
level: 'day',
label: format(new Date(d + 'T00:00:00'), 'EEE, MMM d'),
value: dayTotal,
children: itemNodes,
});
}
yearTotal += monthTotal;
months.push({
key: m,
level: 'month',
label: format(new Date(m + '-01T00:00:00'), 'MMMM yyyy'),
value: monthTotal,
children: days,
});
}
years.push({
key: y,
level: 'year',
label: y,
value: yearTotal,
children: months,
});
}
return years;
}
// ─── Hierarchy with full date range (including empty days) ───────────────────
/**
* Like buildHierarchy but generates a node for EVERY day between startDate
* and endDate (inclusive), even days with no entries. Entries outside the
* range are ignored. Dates are descending (newest first).
*/
export function buildHierarchyForRange(
entries: Array<WorkEntry | Payment | Expense>,
startDate: ISODate,
endDate: ISODate,
valueOf: (e: WorkEntry | Payment | Expense) => number,
labelOf: (e: WorkEntry | Payment | Expense) => string,
): HierNode[] {
// Index in-range entries by date
const byDate = new Map<string, Array<WorkEntry | Payment | Expense>>();
for (const e of entries) {
if (e.date >= startDate && e.date <= endDate) {
if (!byDate.has(e.date)) byDate.set(e.date, []);
byDate.get(e.date)!.push(e);
}
}
// Generate all dates descending
const dates: string[] = [];
const cur = new Date(endDate + 'T00:00:00');
const start = new Date(startDate + 'T00:00:00');
while (cur >= start) {
dates.push(cur.toISOString().slice(0, 10));
cur.setDate(cur.getDate() - 1);
}
// Group into year > month buckets (order is already descending)
const yearBuckets: Array<{ year: string; months: Array<{ month: string; dates: string[] }> }> = [];
const yearIdx = new Map<string, (typeof yearBuckets)[0]>();
for (const date of dates) {
const y = date.slice(0, 4);
const m = date.slice(0, 7);
if (!yearIdx.has(y)) {
const yb = { year: y, months: [] };
yearBuckets.push(yb);
yearIdx.set(y, yb);
}
const yb = yearIdx.get(y)!;
let mb = yb.months.find((x) => x.month === m);
if (!mb) { mb = { month: m, dates: [] }; yb.months.push(mb); }
mb.dates.push(date);
}
// Build HierNode tree
return yearBuckets.map(({ year, months }) => {
const monthNodes = months.map(({ month, dates: ds }) => {
const dayNodes: HierNode[] = ds.map((d) => {
const items = byDate.get(d) ?? [];
const itemNodes: HierNode[] = items.map((e) => ({
key: e.id,
level: 'item',
label: labelOf(e),
value: valueOf(e),
children: [],
entry: e,
}));
return {
key: d,
level: 'day',
label: format(new Date(d + 'T00:00:00'), 'EEE, MMM d'),
value: itemNodes.reduce((s, n) => s + n.value, 0),
children: itemNodes,
};
});
return {
key: month,
level: 'month',
label: format(new Date(month + '-01T00:00:00'), 'MMMM yyyy'),
value: dayNodes.reduce((s, n) => s + n.value, 0),
children: dayNodes,
};
});
return {
key: year,
level: 'year',
label: year,
value: monthNodes.reduce((s, n) => s + n.value, 0),
children: monthNodes,
};
});
}
// ─── Chart data series ───────────────────────────────────────────────────────
export interface ChartPoint {
label: string;
[metric: string]: number | string;
}
export function buildChartSeries(
work: WorkEntry[],
payments: Payment[],
expenses: Expense[],
metrics: ChartMetric[],
granularity: ChartGranularity,
rangeStart: ISODate | null,
rangeEnd: ISODate | null,
): ChartPoint[] {
const agg = aggregate(work, payments, expenses);
// Pick source map based on granularity
let points: Array<{ key: string; stats: PeriodStats }>;
if (granularity === 'year') {
points = agg.years.map((y) => ({ key: y.label, stats: y }));
} else if (granularity === 'month') {
points = [...agg.months.entries()].map(([k, s]) => ({ key: k, stats: s }));
} else if (granularity === 'day') {
points = [...agg.days.entries()].map(([k, s]) => ({ key: k, stats: s }));
} else {
// week: group days by ISO week
const weekMap = new Map<string, PeriodStats>();
for (const [k, s] of agg.days) {
const d = new Date(k + 'T00:00:00');
const ws = startOfWeek(d, { weekStartsOn: 1 });
const wk = format(ws, 'yyyy-MM-dd');
if (!weekMap.has(wk)) weekMap.set(wk, blank(wk));
const w = weekMap.get(wk)!;
w.workValue += s.workValue;
w.payments += s.payments;
w.expenses += s.expenses;
w.net += s.net;
}
points = [...weekMap.entries()].map(([k, s]) => ({ key: k, stats: s }));
}
// Filter by range
if (rangeStart) points = points.filter((p) => p.key >= rangeStart);
if (rangeEnd) points = points.filter((p) => p.key <= rangeEnd);
// Sort ascending for charts
points.sort((a, b) => a.key.localeCompare(b.key));
// Cumulative state
let cumPayments = 0;
let cumNet = 0;
return points.map((p) => {
cumPayments += p.stats.payments;
cumNet += p.stats.net;
const out: ChartPoint = { label: p.key };
for (const m of metrics) {
switch (m) {
case 'workValue': out[m] = round2(p.stats.workValue); break;
case 'payments': out[m] = round2(p.stats.payments); break;
case 'expenses': out[m] = round2(p.stats.expenses); break;
case 'netIncome': out[m] = round2(p.stats.net); break;
case 'cumulativePayments': out[m] = round2(cumPayments); break;
case 'cumulativeNet': out[m] = round2(cumNet); break;
}
}
return out;
});
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}

151
src/lib/storage/adapters.ts Normal file
View file

@ -0,0 +1,151 @@
/**
* Storage Adapters
*
* Two backends, one interface. All backends store an ENCRYPTED blob;
* encryption happens in the caller (store layer) before hitting any adapter.
*
* - CookieStorage: primary storage chunked across browser cookies
* - FileStorage: used only for manual import/export, not live persistence
*/
export interface StorageAdapter {
save(encryptedBlob: string): Promise<void>;
load(): Promise<string | null>;
clear(): Promise<void>;
}
// ─── Cookie ──────────────────────────────────────────────────────────────────
/**
* Cookies have a ~4KB limit per cookie. We chunk the encrypted blob across
* multiple cookies. For realistic datasets this is fine since the blob is
* encrypted + JSON; a heavy user might hit ~20KB 5 cookies.
*
* We also use localStorage as a mirror cookies are the primary (so the
* blob survives localStorage being cleared), localStorage is the fast path.
*/
const COOKIE_PREFIX = 't99_';
const COOKIE_CHUNK_SIZE = 3800;
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 2; // 2 years
export class CookieStorage implements StorageAdapter {
constructor(private namespace: string) {}
private key(i: number | 'count'): string {
return `${COOKIE_PREFIX}${this.namespace}_${i}`;
}
async save(blob: string): Promise<void> {
// Mirror to localStorage for fast reads
try {
localStorage.setItem(this.key('count'), blob);
} catch {
/* quota exceeded — cookies still work */
}
// Clear old chunks first
const oldCount = this.readCookieInt(this.key('count'));
for (let i = 0; i < oldCount; i++) this.deleteCookie(this.key(i));
// Write new chunks
const chunks = chunkString(blob, COOKIE_CHUNK_SIZE);
chunks.forEach((c, i) => this.writeCookie(this.key(i), c));
this.writeCookie(this.key('count'), String(chunks.length));
}
async load(): Promise<string | null> {
// Fast path
try {
const ls = localStorage.getItem(this.key('count'));
if (ls && ls.length > 10) return ls; // heuristic: actual blob, not a count
} catch {
/* ignore */
}
// Cookie reassembly
const count = this.readCookieInt(this.key('count'));
if (count === 0) return null;
let out = '';
for (let i = 0; i < count; i++) {
const c = this.readCookie(this.key(i));
if (c == null) return null; // corrupted
out += c;
}
return out || null;
}
async clear(): Promise<void> {
try {
localStorage.removeItem(this.key('count'));
} catch {
/* ignore */
}
const count = this.readCookieInt(this.key('count'));
for (let i = 0; i < count; i++) this.deleteCookie(this.key(i));
this.deleteCookie(this.key('count'));
}
private writeCookie(name: string, value: string): void {
document.cookie = `${name}=${encodeURIComponent(value)}; max-age=${COOKIE_MAX_AGE}; path=/; SameSite=Strict`;
}
private readCookie(name: string): string | null {
const match = document.cookie.match(
new RegExp(`(?:^|; )${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}=([^;]*)`),
);
return match ? decodeURIComponent(match[1]) : null;
}
private readCookieInt(name: string): number {
const v = this.readCookie(name);
const n = v ? parseInt(v, 10) : 0;
return Number.isFinite(n) ? n : 0;
}
private deleteCookie(name: string): void {
document.cookie = `${name}=; max-age=0; path=/; SameSite=Strict`;
}
}
// ─── File ────────────────────────────────────────────────────────────────────
/**
* File storage triggers a download on save() and opens a file picker
* on load(). Because file-picker is inherently user-initiated, load()
* here accepts a File object from an <input type="file">.
*/
export class FileStorage implements StorageAdapter {
private pendingFile: File | null = null;
/** Call this from your file-input change handler before load() */
setFile(f: File): void {
this.pendingFile = f;
}
async save(blob: string): Promise<void> {
const data = new Blob([blob], { type: 'application/octet-stream' });
const url = URL.createObjectURL(data);
const a = document.createElement('a');
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
a.href = url;
a.download = `ten99timecard-${ts}.t99`;
a.click();
URL.revokeObjectURL(url);
}
async load(): Promise<string | null> {
if (!this.pendingFile) return null;
const text = await this.pendingFile.text();
this.pendingFile = null;
return text;
}
async clear(): Promise<void> {
/* No-op: we don't manage the user's filesystem */
}
}
// ─── helpers ─────────────────────────────────────────────────────────────────
function chunkString(s: string, size: number): string[] {
const out: string[] = [];
for (let i = 0; i < s.length; i += size) out.push(s.slice(i, i + size));
return out;
}

103
src/lib/storage/vault.ts Normal file
View file

@ -0,0 +1,103 @@
/**
* Vault the bridge between app state, encryption, and storage adapters.
*/
import type { AppData } from '@/types';
import { encrypt, decrypt } from '@/lib/crypto/encryption';
import { CookieStorage, FileStorage, type StorageAdapter } from './adapters';
// ─── .t99 file format ────────────────────────────────────────────────────────
type T99File =
| { t99: 1; encrypted: false; data: AppData }
| { t99: 1; encrypted: true; blob: string };
/** Returns true if the file content requires a password to import. */
export function isT99Encrypted(content: string): boolean {
try {
const f = JSON.parse(content) as T99File;
if (f.t99 === 1) return f.encrypted;
} catch { /* fall through */ }
// Legacy format: raw base64 encrypted blob (no JSON wrapper)
return true;
}
/** Serialize AppData to .t99 file content, optionally encrypting it. */
export async function serializeT99(data: AppData, password?: string): Promise<string> {
if (password) {
const blob = await encrypt(JSON.stringify(data), password);
return JSON.stringify({ t99: 1, encrypted: true, blob } satisfies T99File);
}
return JSON.stringify({ t99: 1, encrypted: false, data } satisfies T99File);
}
/** Parse .t99 file content back to AppData. Supply password only if encrypted. */
export async function deserializeT99(content: string, password?: string): Promise<AppData> {
try {
const f = JSON.parse(content) as T99File;
if (f.t99 === 1) {
if (!f.encrypted) return f.data;
if (!password) throw new Error('Password required to decrypt this file');
return JSON.parse(await decrypt(f.blob, password));
}
} catch (e) {
if (e instanceof Error && e.message.includes('Password required')) throw e;
// Not our JSON wrapper — fall through to legacy format
}
// Legacy format: raw base64 encrypted blob
if (!password) throw new Error('Password required to decrypt this file');
return JSON.parse(await decrypt(content, password));
}
// ─── Vault ───────────────────────────────────────────────────────────────────
export type VaultMode = 'cookie' | 'file';
export interface VaultConfig {
mode: VaultMode;
username: string;
password: string;
}
export class Vault {
private adapter: StorageAdapter;
private password: string;
public readonly mode: VaultMode;
/** File adapter is held separately so callers can set a pending file before load() */
public readonly fileAdapter: FileStorage;
constructor(cfg: VaultConfig) {
this.mode = cfg.mode;
this.password = cfg.password;
this.fileAdapter = new FileStorage();
this.adapter = cfg.mode === 'file' ? this.fileAdapter : new CookieStorage(cfg.username);
}
async save(data: AppData): Promise<void> {
const blob = await encrypt(JSON.stringify(data), this.password);
await this.adapter.save(blob);
}
async load(): Promise<AppData | null> {
const blob = await this.adapter.load();
if (!blob) return null;
const json = await decrypt(blob, this.password);
return JSON.parse(json);
}
async clear(): Promise<void> {
await this.adapter.clear();
}
/** Export current data as a .t99 file. Encrypts only if a password is given. */
async exportToFile(data: AppData, password?: string): Promise<void> {
const content = await serializeT99(data, password);
await this.fileAdapter.save(content);
}
}
/** Check if a user already has encrypted data under their username (cookie mode) */
export async function cookieDataExists(username: string): Promise<boolean> {
const cs = new CookieStorage(username);
return (await cs.load()) != null;
}

229
src/lib/tax/brackets.ts Normal file
View file

@ -0,0 +1,229 @@
/**
* US Federal tax data 2024 & 2025 tax years.
* Sources: IRS Rev. Proc. 2023-34 (TY2024) and Rev. Proc. 2024-40 (TY2025).
* All figures in USD.
*/
import type { FilingStatus } from '@/types';
export interface Bracket {
/** Upper bound of this bracket (Infinity for top) */
upTo: number;
/** Marginal rate as a decimal */
rate: number;
}
export interface TaxYearData {
year: number;
brackets: Record<FilingStatus, Bracket[]>;
standardDeduction: Record<FilingStatus, number>;
/** Social Security wage base (SE tax applies 12.4% up to this) */
ssWageBase: number;
/** Additional Medicare Tax threshold */
addlMedicareThreshold: Record<FilingStatus, number>;
/** QBI (§199A) phase-out begins at this taxable income */
qbiPhaseoutStart: Record<FilingStatus, number>;
/** QBI phase-out range width */
qbiPhaseoutRange: Record<FilingStatus, number>;
/** Quarterly estimated payment due dates (ISO) */
quarterlyDueDates: [string, string, string, string];
}
// ─── 2024 ────────────────────────────────────────────────────────────────────
const TY2024: TaxYearData = {
year: 2024,
brackets: {
single: [
{ upTo: 11600, rate: 0.10 },
{ upTo: 47150, rate: 0.12 },
{ upTo: 100525, rate: 0.22 },
{ upTo: 191950, rate: 0.24 },
{ upTo: 243725, rate: 0.32 },
{ upTo: 609350, rate: 0.35 },
{ upTo: Infinity, rate: 0.37 },
],
mfj: [
{ upTo: 23200, rate: 0.10 },
{ upTo: 94300, rate: 0.12 },
{ upTo: 201050, rate: 0.22 },
{ upTo: 383900, rate: 0.24 },
{ upTo: 487450, rate: 0.32 },
{ upTo: 731200, rate: 0.35 },
{ upTo: Infinity, rate: 0.37 },
],
mfs: [
{ upTo: 11600, rate: 0.10 },
{ upTo: 47150, rate: 0.12 },
{ upTo: 100525, rate: 0.22 },
{ upTo: 191950, rate: 0.24 },
{ upTo: 243725, rate: 0.32 },
{ upTo: 365600, rate: 0.35 },
{ upTo: Infinity, rate: 0.37 },
],
hoh: [
{ upTo: 16550, rate: 0.10 },
{ upTo: 63100, rate: 0.12 },
{ upTo: 100500, rate: 0.22 },
{ upTo: 191950, rate: 0.24 },
{ upTo: 243700, rate: 0.32 },
{ upTo: 609350, rate: 0.35 },
{ upTo: Infinity, rate: 0.37 },
],
},
standardDeduction: {
single: 14600,
mfj: 29200,
mfs: 14600,
hoh: 21900,
},
ssWageBase: 168600,
addlMedicareThreshold: {
single: 200000,
mfj: 250000,
mfs: 125000,
hoh: 200000,
},
qbiPhaseoutStart: {
single: 191950,
mfj: 383900,
mfs: 191950,
hoh: 191950,
},
qbiPhaseoutRange: {
single: 50000,
mfj: 100000,
mfs: 50000,
hoh: 50000,
},
quarterlyDueDates: ['2024-04-15', '2024-06-17', '2024-09-16', '2025-01-15'],
};
// ─── 2025 ────────────────────────────────────────────────────────────────────
const TY2025: TaxYearData = {
year: 2025,
brackets: {
single: [
{ upTo: 11925, rate: 0.10 },
{ upTo: 48475, rate: 0.12 },
{ upTo: 103350, rate: 0.22 },
{ upTo: 197300, rate: 0.24 },
{ upTo: 250525, rate: 0.32 },
{ upTo: 626350, rate: 0.35 },
{ upTo: Infinity, rate: 0.37 },
],
mfj: [
{ upTo: 23850, rate: 0.10 },
{ upTo: 96950, rate: 0.12 },
{ upTo: 206700, rate: 0.22 },
{ upTo: 394600, rate: 0.24 },
{ upTo: 501050, rate: 0.32 },
{ upTo: 751600, rate: 0.35 },
{ upTo: Infinity, rate: 0.37 },
],
mfs: [
{ upTo: 11925, rate: 0.10 },
{ upTo: 48475, rate: 0.12 },
{ upTo: 103350, rate: 0.22 },
{ upTo: 197300, rate: 0.24 },
{ upTo: 250525, rate: 0.32 },
{ upTo: 375800, rate: 0.35 },
{ upTo: Infinity, rate: 0.37 },
],
hoh: [
{ upTo: 17000, rate: 0.10 },
{ upTo: 64850, rate: 0.12 },
{ upTo: 103350, rate: 0.22 },
{ upTo: 197300, rate: 0.24 },
{ upTo: 250500, rate: 0.32 },
{ upTo: 626350, rate: 0.35 },
{ upTo: Infinity, rate: 0.37 },
],
},
standardDeduction: {
single: 15000,
mfj: 30000,
mfs: 15000,
hoh: 22500,
},
ssWageBase: 176100,
addlMedicareThreshold: {
single: 200000,
mfj: 250000,
mfs: 125000,
hoh: 200000,
},
qbiPhaseoutStart: {
single: 197300,
mfj: 394600,
mfs: 197300,
hoh: 197300,
},
qbiPhaseoutRange: {
single: 50000,
mfj: 100000,
mfs: 50000,
hoh: 50000,
},
quarterlyDueDates: ['2025-04-15', '2025-06-16', '2025-09-15', '2026-01-15'],
};
// ─── Registry ────────────────────────────────────────────────────────────────
const YEARS: Record<number, TaxYearData> = {
2024: TY2024,
2025: TY2025,
};
/** Advance a date past any weekend (Saturday→Monday, Sunday→Monday). */
function nextWeekday(date: Date): Date {
const d = new Date(date);
if (d.getDay() === 6) d.setDate(d.getDate() + 2); // Sat→Mon
else if (d.getDay() === 0) d.setDate(d.getDate() + 1); // Sun→Mon
return d;
}
/** Generate standard IRS quarterly estimated-payment due dates for a given tax year. */
function quarterlyDueDatesForYear(year: number): [string, string, string, string] {
const fmt = (d: Date) => d.toISOString().slice(0, 10);
return [
fmt(nextWeekday(new Date(year, 3, 15))), // Apr 15
fmt(nextWeekday(new Date(year, 5, 15))), // Jun 15
fmt(nextWeekday(new Date(year, 8, 15))), // Sep 15
fmt(nextWeekday(new Date(year + 1, 0, 15))), // Jan 15 of following year
];
}
export function getTaxYearData(year: number): TaxYearData {
const d = YEARS[year];
if (d) return d;
// Fall back to the closest known year so the app degrades gracefully,
// but override quarterly due dates with correct dates for the requested year.
const known = Object.keys(YEARS).map(Number).sort((a, b) => a - b);
const closest = known.reduce((best, y) =>
Math.abs(y - year) < Math.abs(best - year) ? y : best
, known[0]);
return { ...YEARS[closest], year, quarterlyDueDates: quarterlyDueDatesForYear(year) };
}
export function availableTaxYears(): number[] {
const defined = Object.keys(YEARS).map(Number);
const currentYear = new Date().getFullYear();
const all = defined.includes(currentYear) ? defined : [...defined, currentYear];
return all.sort((a, b) => b - a);
}
/** Apply progressive brackets to a taxable income. */
export function applyBrackets(brackets: Bracket[], taxable: number): number {
if (taxable <= 0) return 0;
let tax = 0;
let lower = 0;
for (const b of brackets) {
const width = Math.min(taxable, b.upTo) - lower;
if (width > 0) tax += width * b.rate;
if (taxable <= b.upTo) break;
lower = b.upTo;
}
return tax;
}

359
src/lib/tax/calculate.ts Normal file
View file

@ -0,0 +1,359 @@
/**
* 1099 Tax Calculation Engine
*
* Computes self-employment tax, federal income tax, quarterly estimates,
* safe-harbor amounts, and produces UI prompts for any missing inputs
* that would sharpen the calculation.
*
* This is NOT tax advice. It is a planning estimator.
*/
import type {
Payment,
Expense,
TaxInputs,
TaxResult,
TaxPrompt,
QuarterlyPayment,
} from '@/types';
import { getTaxYearData, applyBrackets } from './brackets';
import type { TaxYearData } from './brackets';
// SE tax constants (these never change year-to-year)
const SE_ADJUSTMENT = 0.9235; // 92.35% of net profit is subject to SE tax
const SS_RATE = 0.124; // 12.4% Social Security (both halves)
const MEDICARE_RATE = 0.029; // 2.9% Medicare (both halves)
const ADDL_MEDICARE_RATE = 0.009; // 0.9% on high earners
const QBI_RATE = 0.20; // 20% qualified business income deduction cap
const SAFE_HARBOR_HIGH_AGI = 150000; // Threshold for 110% rule
interface CalculateOptions {
/** Override "today" for deterministic testing & past-due flags */
asOf?: Date;
/** If true, project full-year income from YTD run rate */
project?: boolean;
}
export function calculateTax(
payments: Payment[],
expenses: Expense[],
inputs: TaxInputs,
opts: CalculateOptions = {},
): TaxResult {
const asOf = opts.asOf ?? new Date();
const data = getTaxYearData(inputs.taxYear);
const prompts: TaxPrompt[] = [];
const notes: string[] = [];
// ─── 1. Schedule C: gross receipts & net profit ───────────────────────────
const yearPayments = payments.filter((p) => p.date.startsWith(String(inputs.taxYear)));
const yearExpenses = expenses.filter((e) => e.date.startsWith(String(inputs.taxYear)));
let grossReceipts = sum(yearPayments.map((p) => p.amount));
let deductibleExpenses = sum(
yearExpenses.filter((e) => e.deductible).map((e) => e.amount),
);
// Projection: scale YTD to full year based on fraction of year elapsed
if (opts.project && isCurrentOrFutureYear(inputs.taxYear, asOf)) {
const frac = fractionOfYearElapsed(inputs.taxYear, asOf);
if (frac > 0 && frac < 1) {
grossReceipts = grossReceipts / frac;
deductibleExpenses = deductibleExpenses / frac;
notes.push(
`Projected from ${(frac * 100).toFixed(0)}% of year elapsed. ` +
`Actual YTD receipts: $${sum(yearPayments.map((p) => p.amount)).toFixed(2)}`,
);
}
}
const netProfit = Math.max(0, grossReceipts - deductibleExpenses);
// ─── 2. Self-Employment Tax (Schedule SE) ─────────────────────────────────
const seTaxableBase = netProfit * SE_ADJUSTMENT;
// Social Security portion capped at wage base (minus any W-2 wages already
// subject to SS, since the cap is combined)
const w2Wages = inputs.w2Wages ?? 0;
const ssRoom = Math.max(0, data.ssWageBase - w2Wages);
const ssTaxableAmount = Math.min(seTaxableBase, ssRoom);
const socialSecurityTax = ssTaxableAmount * SS_RATE;
// Medicare — no cap
const medicareTax = seTaxableBase * MEDICARE_RATE;
// Additional Medicare — 0.9% on combined wages+SE above threshold
const addlMedThreshold = data.addlMedicareThreshold[inputs.filingStatus];
const combinedMedicareWages = w2Wages + seTaxableBase;
const addlMedBase = Math.max(0, combinedMedicareWages - addlMedThreshold);
const additionalMedicareTax = addlMedBase * ADDL_MEDICARE_RATE;
const totalSETax = socialSecurityTax + medicareTax + additionalMedicareTax;
// ─── 3. Above-the-line deductions ─────────────────────────────────────────
// Half of SE tax is deductible from gross income
const seTaxDeduction = (socialSecurityTax + medicareTax) * 0.5;
// AGI for our purposes
const agi = Math.max(0, netProfit + w2Wages - seTaxDeduction);
// Standard deduction
const standardDeduction = data.standardDeduction[inputs.filingStatus];
// Taxable income BEFORE QBI (we need this to figure out QBI phaseout)
const preQbiTaxable = Math.max(0, agi - standardDeduction);
// QBI (§199A) — simplified: 20% of qualified business income, limited
// to 20% of (taxable income - net capital gains [we assume 0]),
// with phaseout for high earners.
const qbiBase = netProfit - seTaxDeduction; // qualified business income
let qbiDeduction = Math.min(qbiBase * QBI_RATE, preQbiTaxable * QBI_RATE);
const phaseStart = data.qbiPhaseoutStart[inputs.filingStatus];
const phaseRange = data.qbiPhaseoutRange[inputs.filingStatus];
if (preQbiTaxable > phaseStart) {
// Simplified SSTB phaseout: linearly reduce to zero over the range.
// (Real 199A is more complex; this is a reasonable estimator.)
const excess = preQbiTaxable - phaseStart;
const phaseoutFactor = Math.max(0, 1 - excess / phaseRange);
qbiDeduction *= phaseoutFactor;
if (phaseoutFactor < 1) {
notes.push(`QBI deduction reduced by phaseout (${((1 - phaseoutFactor) * 100).toFixed(0)}% reduction).`);
}
}
qbiDeduction = Math.max(0, qbiDeduction);
const taxableIncome = Math.max(0, preQbiTaxable - qbiDeduction);
// ─── 4. Federal income tax ────────────────────────────────────────────────
const federalIncomeTax = applyBrackets(
data.brackets[inputs.filingStatus],
taxableIncome,
);
const totalFederalTax = federalIncomeTax + totalSETax;
// ─── 5. Payments already made ─────────────────────────────────────────────
const withholding = inputs.federalWithholding ?? 0;
const taxPaymentsSum = (inputs.taxPayments ?? []).reduce((s, p) => s + p.amount, 0);
const estimatedPaid = taxPaymentsSum + (inputs.estimatedPaymentsMade ?? 0);
const alreadyPaid = withholding + estimatedPaid;
const remainingDue = Math.max(0, totalFederalTax - alreadyPaid);
// ─── 6. Quarterly estimated payments & safe harbor ────────────────────────
// Estimated payments are only required if you expect to owe >= $1,000
// after withholding.
const owesEstimates = totalFederalTax - withholding >= 1000;
if (!owesEstimates && totalFederalTax > 0) {
notes.push('Expected to owe less than $1,000 after withholding — quarterly estimates may not be required.');
}
// Safe harbor: you avoid underpayment penalty if you pay the LESSER of:
// (a) 90% of current-year tax, OR
// (b) 100% of prior-year tax (110% if prior-year AGI > $150k)
// We need prior-year figures from the user to compute (b).
let safeHarborAmount: number | null = null;
let safeHarborMet: boolean | null = null;
if (inputs.priorYearTax == null) {
prompts.push({
field: 'priorYearTax',
label: 'Previous year total federal tax',
reason:
'Lets us calculate the safe-harbor minimum — you avoid underpayment penalties if you pay at least this much, even if you underestimate this year.',
severity: 'recommended',
});
}
if (inputs.priorYearAGI == null) {
prompts.push({
field: 'priorYearAGI',
label: 'Previous year adjusted gross income (AGI)',
reason:
'If your prior-year AGI was over $150,000, the safe harbor is 110% (not 100%) of last year\'s tax.',
severity: 'recommended',
});
}
if (inputs.priorYearTax != null) {
const highEarner = (inputs.priorYearAGI ?? 0) > SAFE_HARBOR_HIGH_AGI;
const priorYearMultiplier = highEarner ? 1.10 : 1.00;
const priorYearSafeHarbor = inputs.priorYearTax * priorYearMultiplier;
const currentYearSafeHarbor = totalFederalTax * 0.90;
safeHarborAmount = Math.min(priorYearSafeHarbor, currentYearSafeHarbor);
safeHarborMet = alreadyPaid >= safeHarborAmount;
if (highEarner) {
notes.push('Prior-year AGI > $150k: 110% safe-harbor rule applies.');
}
}
// Quarterly schedule — IRS annualized income installment method (Form 2210, Schedule AI)
// Periods: JanMar (×4, 22.5%), JanMay (×2.4, 45%), JanAug (×1.5, 67.5%), JanDec (×1.0, 100%)
const ANNUALIZED_PERIODS = [
{ periodEnd: `${inputs.taxYear}-03-31`, factor: 4.0, cumPct: 0.225 },
{ periodEnd: `${inputs.taxYear}-05-31`, factor: 2.4, cumPct: 0.450 },
{ periodEnd: `${inputs.taxYear}-08-31`, factor: 1.5, cumPct: 0.675 },
{ periodEnd: `${inputs.taxYear}-12-31`, factor: 1.0, cumPct: 1.000 },
];
// Cumulative tax due through each quarter (based on annualized YTD income)
const cumulativeDue = ANNUALIZED_PERIODS.map(({ periodEnd, factor, cumPct }) => {
const periodGross =
sum(yearPayments.filter((p) => p.date <= periodEnd).map((p) => p.amount)) * factor;
const periodDeductible =
sum(yearExpenses.filter((e) => e.date <= periodEnd && e.deductible).map((e) => e.amount)) * factor;
const annualizedTax = computeAnnualizedTax(periodGross, periodDeductible, inputs, data);
return round2(annualizedTax * cumPct);
});
const safeHarborPerQuarter =
safeHarborAmount != null ? Math.max(0, safeHarborAmount - withholding) / 4 : null;
const asOfISO = asOf.toISOString().slice(0, 10);
const isPastDueFlags = data.quarterlyDueDates.map((d) => d < asOfISO);
const quarterlySchedule: QuarterlyPayment[] = data.quarterlyDueDates.map((dueDate, i) => {
// Per-quarter installment = marginal increase in cumulative obligation
const projectedAmount = round2(
Math.max(0, cumulativeDue[i] - (i > 0 ? cumulativeDue[i - 1] : 0)),
);
// Payments credited through this quarter's due date
const taxPaidBeforeDue = (inputs.taxPayments ?? [])
.filter((p) => p.date <= dueDate)
.reduce((s, p) => s + p.amount, 0);
// Withholding and legacy estimated payments credited proportionally by cumulative %
const withholdingCredit =
(withholding + (inputs.estimatedPaymentsMade ?? 0)) * ANNUALIZED_PERIODS[i].cumPct;
const remainingAmount = round2(
Math.max(0, cumulativeDue[i] - taxPaidBeforeDue - withholdingCredit),
);
return {
quarter: (i + 1) as 1 | 2 | 3 | 4,
dueDate,
projectedAmount,
remainingAmount,
safeHarborAmount: safeHarborPerQuarter != null ? round2(safeHarborPerQuarter) : null,
isPastDue: isPastDueFlags[i],
};
});
// ─── 7. Additional prompts ────────────────────────────────────────────────
if (inputs.w2Wages == null) {
prompts.push({
field: 'w2Wages',
label: 'W-2 wages (if any)',
reason:
'If you also have a W-2 job, those wages affect your tax bracket and the Social Security cap.',
severity: 'recommended',
});
}
if (inputs.federalWithholding == null && (inputs.w2Wages ?? 0) > 0) {
prompts.push({
field: 'federalWithholding',
label: 'Federal tax already withheld',
reason: 'Withholding from your W-2 counts toward your estimated-payment obligation.',
severity: 'required',
});
}
// ─── 8. Assemble ──────────────────────────────────────────────────────────
return {
taxYear: inputs.taxYear,
agi: round2(agi),
grossReceipts: round2(grossReceipts),
deductibleExpenses: round2(deductibleExpenses),
netProfit: round2(netProfit),
seTaxableBase: round2(seTaxableBase),
socialSecurityTax: round2(socialSecurityTax),
medicareTax: round2(medicareTax),
additionalMedicareTax: round2(additionalMedicareTax),
totalSETax: round2(totalSETax),
seTaxDeduction: round2(seTaxDeduction),
qbiDeduction: round2(qbiDeduction),
standardDeduction,
taxableIncome: round2(taxableIncome),
federalIncomeTax: round2(federalIncomeTax),
totalFederalTax: round2(totalFederalTax),
alreadyPaid: round2(alreadyPaid),
remainingDue: round2(remainingDue),
quarterlySchedule,
safeHarborAmount: safeHarborAmount != null ? round2(safeHarborAmount) : null,
safeHarborMet,
prompts,
notes,
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function sum(xs: number[]): number {
return xs.reduce((a, b) => a + b, 0);
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
function isCurrentOrFutureYear(year: number, asOf: Date): boolean {
return year >= asOf.getFullYear();
}
/**
* Compute total federal tax (SE + income) for a given gross/deductible pair.
* Used by the annualized income installment method to compute per-period tax.
*/
function computeAnnualizedTax(
grossReceipts: number,
deductibleExpenses: number,
inputs: TaxInputs,
data: TaxYearData,
): number {
const netProfit = Math.max(0, grossReceipts - deductibleExpenses);
const seTaxableBase = netProfit * SE_ADJUSTMENT;
const w2Wages = inputs.w2Wages ?? 0;
const ssRoom = Math.max(0, data.ssWageBase - w2Wages);
const ssTaxableAmount = Math.min(seTaxableBase, ssRoom);
const socialSecurityTax = ssTaxableAmount * SS_RATE;
const medicareTax = seTaxableBase * MEDICARE_RATE;
const addlMedBase = Math.max(
0,
w2Wages + seTaxableBase - data.addlMedicareThreshold[inputs.filingStatus],
);
const totalSETax = socialSecurityTax + medicareTax + addlMedBase * ADDL_MEDICARE_RATE;
const seTaxDeduction = (socialSecurityTax + medicareTax) * 0.5;
const agi = Math.max(0, netProfit + w2Wages - seTaxDeduction);
const preQbiTaxable = Math.max(0, agi - data.standardDeduction[inputs.filingStatus]);
const qbiBase = netProfit - seTaxDeduction;
let qbiDeduction = Math.min(qbiBase * QBI_RATE, preQbiTaxable * QBI_RATE);
const phaseStart = data.qbiPhaseoutStart[inputs.filingStatus];
const phaseRange = data.qbiPhaseoutRange[inputs.filingStatus];
if (preQbiTaxable > phaseStart) {
qbiDeduction *= Math.max(0, 1 - (preQbiTaxable - phaseStart) / phaseRange);
}
const taxableIncome = Math.max(0, preQbiTaxable - Math.max(0, qbiDeduction));
return totalSETax + applyBrackets(data.brackets[inputs.filingStatus], taxableIncome);
}
function fractionOfYearElapsed(year: number, asOf: Date): number {
if (asOf.getFullYear() < year) return 0;
if (asOf.getFullYear() > year) return 1;
// Use completed whole months for a smoother, less volatile projection.
// asOf.getMonth() === 0 means we're in January (0 months complete).
const completedMonths = asOf.getMonth();
if (completedMonths === 0) {
// In January: use day-fraction so we have at least some projection basis
const start = new Date(year, 0, 1).getTime();
const end = new Date(year + 1, 0, 1).getTime();
return (asOf.getTime() - start) / (end - start);
}
return completedMonths / 12;
}

14
src/main.tsx Normal file
View file

@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import { applyTheme } from './themes/ThemeProvider';
import './themes/global.css';
// Apply default theme immediately to avoid flash-of-unstyled-content
applyTheme('standard', 'dark');
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

192
src/pages/DashboardPage.tsx Normal file
View file

@ -0,0 +1,192 @@
/**
* Dashboard configurable at-a-glance view.
*/
import { useMemo, useState } from 'react';
import { useAppStore } from '@/store/appStore';
import { aggregate } from '@/lib/stats/aggregate';
import { calculateTax } from '@/lib/tax/calculate';
import { fmtMoney, fmtMoneyShort } from '@/lib/format';
import { ChartPanel } from '@/components/charts/ChartPanel';
import { Modal } from '@/components/common/Modal';
import type { DashboardWidget } from '@/types';
const WIDGET_LABELS: Record<DashboardWidget, string> = {
ytdWorkValue: 'YTD Work Value',
ytdWorkProj: 'Work Value Projected',
ytdPayments: 'YTD Payments',
ytdPaymentsProj: 'Payments Projected',
ytdExpenses: 'YTD Expenses',
ytdNet: 'YTD Net Income',
ytdNetProj: 'Net Income Projected',
nextQuarterlyDue: 'Next Quarterly Due',
projectedAnnualTax: 'Projected Annual Tax',
ytdActualTax: 'YTD Actual Tax',
taxRemainingDue: 'Tax Remaining Due',
avgMonthlyNet: 'Avg Monthly Net',
avgDailyWork: 'Avg Daily Work',
};
export function DashboardPage() {
const data = useAppStore((s) => s.data);
const addChart = useAppStore((s) => s.addChart);
const updateChart = useAppStore((s) => s.updateChart);
const removeChart = useAppStore((s) => s.removeChart);
const setWidgets = useAppStore((s) => s.setDashboardWidgets);
const [configOpen, setConfigOpen] = useState(false);
const stats = useMemo(
() => aggregate(data.workEntries, data.payments, data.expenses),
[data.workEntries, data.payments, data.expenses],
);
const currentYear = new Date().getFullYear();
const currentYearStats = stats.years.find((y) => y.label === String(currentYear));
const taxInputs = data.taxInputs[currentYear] ?? { taxYear: currentYear, filingStatus: 'single' as const };
// Auto-populate prior year from stored data if not manually entered
const prevYearInputs = data.taxInputs[currentYear - 1];
const prevYearResult = useMemo(
() => prevYearInputs ? calculateTax(data.payments, data.expenses, prevYearInputs, { project: false }) : null,
[data.payments, data.expenses, prevYearInputs],
);
const effectiveTaxInputs = useMemo(() => ({
...taxInputs,
priorYearTax: taxInputs.priorYearTax ?? prevYearResult?.totalFederalTax,
priorYearAGI: taxInputs.priorYearAGI ?? prevYearResult?.agi,
}), [taxInputs, prevYearResult]);
const taxResult = useMemo(
() => calculateTax(data.payments, data.expenses, effectiveTaxInputs, { project: true }),
[data.payments, data.expenses, effectiveTaxInputs],
);
const ytdTaxResult = useMemo(
() => calculateTax(data.payments, data.expenses, effectiveTaxInputs, { project: false }),
[data.payments, data.expenses, effectiveTaxInputs],
);
const nextQuarter = taxResult.quarterlySchedule.find((q) => !q.isPastDue);
// Year projection fraction (day-based for work/payment/expense projections)
const now = new Date();
const yearStart = new Date(now.getFullYear(), 0, 1).getTime();
const yearEnd = new Date(now.getFullYear() + 1, 0, 1).getTime();
const yearFrac = (now.getTime() - yearStart) / (yearEnd - yearStart);
const proj = (v: number) => (v > 0 && yearFrac > 0 && yearFrac < 1) ? v / yearFrac : null;
const ytdWork = currentYearStats?.workValue ?? 0;
const ytdPayments = currentYearStats?.payments ?? 0;
const ytdExpenses = currentYearStats?.expenses ?? 0;
const ytdNet = currentYearStats?.net ?? 0;
const widgets: Record<DashboardWidget, { value: string; sub?: string; className?: string }> = {
ytdWorkValue: { value: fmtMoneyShort(ytdWork) },
ytdWorkProj: { value: fmtMoneyShort(proj(ytdWork)), sub: 'full year est.' },
ytdPayments: { value: fmtMoneyShort(ytdPayments) },
ytdPaymentsProj: { value: fmtMoneyShort(proj(ytdPayments)), sub: 'full year est.' },
ytdExpenses: { value: fmtMoneyShort(ytdExpenses), className: 'negative' },
ytdNet: {
value: fmtMoneyShort(ytdNet),
className: ytdNet >= 0 ? 'positive' : 'negative',
},
ytdNetProj: { value: fmtMoneyShort(proj(ytdNet)), sub: 'full year est.' },
nextQuarterlyDue: {
value: nextQuarter ? fmtMoneyShort(nextQuarter.remainingAmount) : '—',
sub: nextQuarter ? `Q${nextQuarter.quarter} due ${nextQuarter.dueDate}` : 'All paid',
},
projectedAnnualTax: {
value: fmtMoneyShort(taxResult.totalFederalTax),
sub: 'full year est.',
className: 'negative',
},
ytdActualTax: {
value: fmtMoneyShort(ytdTaxResult.totalFederalTax),
sub: 'based on actual YTD',
className: 'negative',
},
taxRemainingDue: {
value: fmtMoneyShort(taxResult.remainingDue),
sub: 'after payments made',
className: 'negative',
},
avgMonthlyNet: {
value: fmtMoneyShort(currentYearStats?.avgPerChild ?? 0),
sub: `${currentYearStats?.childCount ?? 0} months`,
},
avgDailyWork: {
value: fmtMoneyShort(
[...stats.days.values()].reduce((s, d) => s + d.workValue, 0) /
Math.max(1, stats.days.size),
),
sub: `${stats.days.size} days logged`,
},
};
const toggleWidget = (w: DashboardWidget) => {
const next = data.dashboard.widgets.includes(w)
? data.dashboard.widgets.filter((x) => x !== w)
: [...data.dashboard.widgets, w];
setWidgets(next);
};
return (
<div className="flex-col gap-4">
<div className="flex items-center justify-between">
<h1>Dashboard</h1>
<button className="btn btn-sm" onClick={() => setConfigOpen(true)}>
Configure
</button>
</div>
{/* Stat widgets */}
<div className="stat-grid">
{data.dashboard.widgets.map((w) => {
const def = widgets[w];
return (
<div key={w} className={`stat-card ${def.className ?? ''}`}>
<div className="stat-label">{WIDGET_LABELS[w]}</div>
<div className="stat-value">{def.value}</div>
{def.sub && <div className="stat-sub">{def.sub}</div>}
</div>
);
})}
</div>
{/* Charts */}
<div className="chart-grid">
{data.dashboard.charts.map((c) => (
<ChartPanel
key={c.id}
config={c}
onChange={(patch) => updateChart(c.id, patch)}
onRemove={data.dashboard.charts.length > 1 ? () => removeChart(c.id) : undefined}
/>
))}
</div>
<button className="btn" onClick={() => addChart()}>+ Add chart</button>
{/* Config modal */}
<Modal
open={configOpen}
title="Configure dashboard"
onClose={() => setConfigOpen(false)}
footer={<button className="btn btn-primary" onClick={() => setConfigOpen(false)}>Done</button>}
>
<div className="flex-col gap-2">
<p className="text-sm text-muted">Choose which stats appear at the top:</p>
{(Object.keys(WIDGET_LABELS) as DashboardWidget[]).map((w) => (
<label key={w} className="checkbox">
<input
type="checkbox"
checked={data.dashboard.widgets.includes(w)}
onChange={() => toggleWidget(w)}
/>
{WIDGET_LABELS[w]}
</label>
))}
</div>
</Modal>
</div>
);
}

721
src/pages/LedgerPage.tsx Normal file
View file

@ -0,0 +1,721 @@
/**
* Shared Ledger page tabs for Work Log, Payments, Expenses.
* Left: hierarchical spreadsheet + entry form + summary stats.
* Right: configurable charts.
*/
import React, { useMemo, useState } from 'react';
import { useAppStore } from '@/store/appStore';
import type { WorkEntry, Payment, Expense, RecurringExpense, RecurringFrequency, LedgerTile } from '@/types';
import { workEntryValue } from '@/types';
import { HierSpreadsheet } from '@/components/spreadsheet/HierSpreadsheet';
import { WorkEntryForm, PaymentForm, ExpenseForm } from '@/components/spreadsheet/EntryForm';
import { ChartSidebar } from '@/components/charts/ChartSidebar';
import { ResizableSplit } from '@/components/layout/ResizableSplit';
import { Modal, ConfirmDialog } from '@/components/common/Modal';
import { buildHierarchyForRange, buildHierarchy, aggregate } from '@/lib/stats/aggregate';
import { fmtMoney, todayISO } from '@/lib/format';
function yearStart() { return `${new Date().getFullYear()}-01-01`; }
/** Sort unique string values by frequency (desc), with the most-recently-used value first. */
function freqSorted(values: Array<{ value: string; createdAt: number }>): string[] {
const freq = new Map<string, number>();
let mruValue = '';
let mruTime = 0;
for (const { value, createdAt } of values) {
if (!value) continue;
freq.set(value, (freq.get(value) ?? 0) + 1);
if (createdAt > mruTime) { mruTime = createdAt; mruValue = value; }
}
const sorted = [...freq.keys()].sort((a, b) => (freq.get(b) ?? 0) - (freq.get(a) ?? 0));
if (mruValue) {
const i = sorted.indexOf(mruValue);
if (i > 0) { sorted.splice(i, 1); sorted.unshift(mruValue); }
}
return sorted;
}
function usePersistedDate(key: string): [string, (v: string) => void] {
const [value, setValue] = useState(() => localStorage.getItem(key) ?? yearStart());
const set = (v: string) => { setValue(v); localStorage.setItem(key, v); };
return [value, set];
}
type Tab = 'work' | 'payments' | 'expenses';
export function LedgerPage({ initialTab = 'work' }: { initialTab?: Tab }) {
const [workStart, setWorkStart] = usePersistedDate('ledger_work_from');
const [paymentsStart, setPaymentsStart] = usePersistedDate('ledger_payments_from');
const [expensesStart, setExpensesStart] = usePersistedDate('ledger_expenses_from');
const today = todayISO();
const startDate =
initialTab === 'work' ? workStart :
initialTab === 'payments' ? paymentsStart :
expensesStart;
return (
<ResizableSplit
left={
<>
{initialTab === 'work' && <WorkTab startDate={workStart} setStartDate={setWorkStart} />}
{initialTab === 'payments' && <PaymentsTab startDate={paymentsStart} setStartDate={setPaymentsStart} />}
{initialTab === 'expenses' && <ExpensesTab startDate={expensesStart} setStartDate={setExpensesStart} />}
</>
}
right={<ChartSidebar tab={initialTab} defaultRangeStart={startDate} defaultRangeEnd={today} />}
/>
);
}
// ─── Work Tab ────────────────────────────────────────────────────────────────
function WorkTab({ startDate, setStartDate }: { startDate: string; setStartDate: (v: string) => void }) {
const entries = useAppStore((s) => s.data.workEntries);
const defaultRate = useAppStore((s) => s.data.settings.defaultRate);
const addWorkEntry = useAppStore((s) => s.addWorkEntry);
const updateWorkEntry = useAppStore((s) => s.updateWorkEntry);
const deleteWorkEntry = useAppStore((s) => s.deleteWorkEntry);
const tiles = useAppStore((s) => s.data.dashboard.workTiles);
const setPageTiles = useAppStore((s) => s.setPageTiles);
const [addForDay, setAddForDay] = useState<string | null>(null);
const [editing, setEditing] = useState<WorkEntry | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [configOpen, setConfigOpen] = useState(false);
const today = todayISO();
const nodes = useMemo(
() =>
buildHierarchyForRange(
entries,
startDate,
today,
(e) => workEntryValue(e as WorkEntry),
(e) => {
const w = e as WorkEntry;
const extra = w.hours != null ? ` (${w.hours}h @ $${w.rate})` : '';
return `${w.description}${extra}${w.client ? `${w.client}` : ''}`;
},
),
[entries, startDate, today],
);
const stats = useMemo(() => aggregate(entries, [], []), [entries]);
const existingClients = useMemo(
() => freqSorted(entries.map((e) => ({ value: e.client ?? '', createdAt: e.createdAt }))),
[entries],
);
const existingDescriptions = useMemo(
() => freqSorted(entries.map((e) => ({ value: e.description, createdAt: e.createdAt }))),
[entries],
);
return (
<>
<PeriodSummaryRow
stats={stats}
metric="workValue"
label="Work value"
tiles={tiles}
onConfigure={() => setConfigOpen(true)}
/>
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div className="card-header">
<span className="card-title">Work Log</span>
<div className="flex items-center gap-2">
<label className="text-sm text-muted">From</label>
<input
type="date"
className="input"
style={{ width: 148, height: 32 }}
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<button className="btn btn-primary btn-sm" onClick={() => setAddForDay(today)}>
+ Add entry
</button>
</div>
</div>
<HierSpreadsheet
nodes={nodes}
valueLabel="Earned"
onAddForDay={(date) => setAddForDay(date)}
onView={(n) => setEditing(n.entry as WorkEntry)}
onEdit={(n) => setEditing(n.entry as WorkEntry)}
onDelete={(n) => setDeleting(n.entry!.id)}
/>
</div>
<Modal open={addForDay != null} title="Add work entry" onClose={() => setAddForDay(null)}>
{addForDay != null && (
<WorkEntryForm
initial={{ date: addForDay }}
defaultRate={defaultRate}
existingClients={existingClients}
existingDescriptions={existingDescriptions}
onSubmit={(d) => { addWorkEntry(d); setAddForDay(null); }}
onCancel={() => setAddForDay(null)}
/>
)}
</Modal>
<Modal open={editing != null} title="Edit work entry" onClose={() => setEditing(null)}>
{editing && (
<WorkEntryForm
initial={editing}
defaultRate={defaultRate}
existingClients={existingClients}
existingDescriptions={existingDescriptions}
onSubmit={(d) => { updateWorkEntry(editing.id, d); setEditing(null); }}
onCancel={() => setEditing(null)}
/>
)}
</Modal>
<ConfirmDialog
open={deleting != null}
title="Delete work entry?"
message="This cannot be undone."
confirmLabel="Delete"
danger
onConfirm={() => { if (deleting) deleteWorkEntry(deleting); setDeleting(null); }}
onCancel={() => setDeleting(null)}
/>
<TileConfigModal
open={configOpen}
tiles={tiles}
onChange={(t) => setPageTiles('work', t)}
onClose={() => setConfigOpen(false)}
/>
</>
);
}
// ─── Payments Tab ────────────────────────────────────────────────────────────
function PaymentsTab({ startDate, setStartDate }: { startDate: string; setStartDate: (v: string) => void }) {
const payments = useAppStore((s) => s.data.payments);
const addPayment = useAppStore((s) => s.addPayment);
const updatePayment = useAppStore((s) => s.updatePayment);
const deletePayment = useAppStore((s) => s.deletePayment);
const tiles = useAppStore((s) => s.data.dashboard.paymentsTiles);
const setPageTiles = useAppStore((s) => s.setPageTiles);
const [addForDay, setAddForDay] = useState<string | null>(null);
const [editing, setEditing] = useState<Payment | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [configOpen, setConfigOpen] = useState(false);
const today = todayISO();
const nodes = useMemo(
() =>
buildHierarchyForRange(
payments,
startDate,
today,
(e) => (e as Payment).amount,
(e) => {
const p = e as Payment;
return `${p.payer} (${p.form ?? 'direct'})${p.notes ? `${p.notes}` : ''}`;
},
),
[payments, startDate, today],
);
const stats = useMemo(() => aggregate([], payments, []), [payments]);
const recentForm = useMemo(
() => payments.slice().sort((a, b) => b.createdAt - a.createdAt)[0]?.form,
[payments],
);
const existingPayers = useMemo(
() => freqSorted(payments.map((p) => ({ value: p.payer, createdAt: p.createdAt }))),
[payments],
);
return (
<>
<PeriodSummaryRow
stats={stats}
metric="payments"
label="Taxable income"
tiles={tiles}
onConfigure={() => setConfigOpen(true)}
/>
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div className="card-header">
<span className="card-title">Payments Received</span>
<div className="flex items-center gap-2">
<label className="text-sm text-muted">From</label>
<input
type="date"
className="input"
style={{ width: 148, height: 32 }}
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<button className="btn btn-primary btn-sm" onClick={() => setAddForDay(today)}>
+ Add payment
</button>
</div>
</div>
<HierSpreadsheet
nodes={nodes}
valueLabel="Amount"
onAddForDay={(date) => setAddForDay(date)}
onView={(n) => setEditing(n.entry as Payment)}
onEdit={(n) => setEditing(n.entry as Payment)}
onDelete={(n) => setDeleting(n.entry!.id)}
/>
</div>
<Modal open={addForDay != null} title="Add payment" onClose={() => setAddForDay(null)}>
{addForDay != null && (
<PaymentForm
initial={{ date: addForDay }}
defaultForm={recentForm}
existingPayers={existingPayers}
onSubmit={(d) => { addPayment(d); setAddForDay(null); }}
onCancel={() => setAddForDay(null)}
/>
)}
</Modal>
<Modal open={editing != null} title="Edit payment" onClose={() => setEditing(null)}>
{editing && (
<PaymentForm
initial={editing}
existingPayers={existingPayers}
onSubmit={(d) => { updatePayment(editing.id, d); setEditing(null); }}
onCancel={() => setEditing(null)}
/>
)}
</Modal>
<ConfirmDialog
open={deleting != null}
title="Delete payment?"
message="This cannot be undone."
confirmLabel="Delete"
danger
onConfirm={() => { if (deleting) deletePayment(deleting); setDeleting(null); }}
onCancel={() => setDeleting(null)}
/>
<TileConfigModal
open={configOpen}
tiles={tiles}
onChange={(t) => setPageTiles('payments', t)}
onClose={() => setConfigOpen(false)}
/>
</>
);
}
// ─── Expenses Tab ────────────────────────────────────────────────────────────
function ExpensesTab({ startDate, setStartDate }: { startDate: string; setStartDate: (v: string) => void }) {
const expenses = useAppStore((s) => s.data.expenses);
const addExpense = useAppStore((s) => s.addExpense);
const updateExpense = useAppStore((s) => s.updateExpense);
const deleteExpense = useAppStore((s) => s.deleteExpense);
const recurringExpenses = useAppStore((s) => s.data.recurringExpenses);
const addRecurringExpense = useAppStore((s) => s.addRecurringExpense);
const updateRecurringExpense = useAppStore((s) => s.updateRecurringExpense);
const deleteRecurringExpense = useAppStore((s) => s.deleteRecurringExpense);
const tiles = useAppStore((s) => s.data.dashboard.expensesTiles);
const setPageTiles = useAppStore((s) => s.setPageTiles);
const [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<Expense | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [showRecurring, setShowRecurring] = useState(false);
const [editingRecurring, setEditingRecurring] = useState<RecurringExpense | null>(null);
const [deletingRecurring, setDeletingRecurring] = useState<string | null>(null);
const [configOpen, setConfigOpen] = useState(false);
const today = todayISO();
const nodes = useMemo(
() =>
buildHierarchyForRange(
expenses,
startDate,
today,
(e) => (e as Expense).amount,
(e) => {
const x = e as Expense;
return `${x.description}${x.deductible ? ' ✓ deductible' : ''}${x.category ? `${x.category}` : ''}`;
},
),
[expenses, startDate, today],
);
const stats = useMemo(() => aggregate([], [], expenses), [expenses]);
return (
<>
<PeriodSummaryRow
stats={stats}
metric="expenses"
label="Expenses"
tiles={tiles}
onConfigure={() => setConfigOpen(true)}
/>
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div className="card-header">
<span className="card-title">Expenses</span>
<div className="flex items-center gap-2">
<label className="text-sm text-muted">From</label>
<input
type="date"
className="input"
style={{ width: 148, height: 32 }}
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<button className="btn btn-primary btn-sm" onClick={() => setShowAdd(true)}>
+ Add expense
</button>
</div>
</div>
<HierSpreadsheet
nodes={nodes}
valueLabel="Amount"
onView={(n) => setEditing(n.entry as Expense)}
onEdit={(n) => setEditing(n.entry as Expense)}
onDelete={(n) => setDeleting(n.entry!.id)}
/>
</div>
<Modal open={showAdd} title="Add expense" onClose={() => setShowAdd(false)}>
<ExpenseForm onSubmit={(d) => { addExpense(d); setShowAdd(false); }} onCancel={() => setShowAdd(false)} />
</Modal>
<Modal open={editing != null} title="Edit expense" onClose={() => setEditing(null)}>
{editing && (
<ExpenseForm
initial={editing}
onSubmit={(d) => { updateExpense(editing.id, d); setEditing(null); }}
onCancel={() => setEditing(null)}
/>
)}
</Modal>
<ConfirmDialog
open={deleting != null}
title="Delete expense?"
message="This cannot be undone."
confirmLabel="Delete"
danger
onConfirm={() => { if (deleting) deleteExpense(deleting); setDeleting(null); }}
onCancel={() => setDeleting(null)}
/>
{/* ─── Recurring expenses ─────────────────────────── */}
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div className="card-header">
<span className="card-title">Recurring Expenses</span>
<button className="btn btn-primary btn-sm" onClick={() => setShowRecurring(true)}>
+ Add recurring
</button>
</div>
{recurringExpenses.length === 0 ? (
<div className="text-muted text-sm" style={{ padding: '12px 16px' }}>
No recurring expenses set up. Add one to auto-generate expense entries.
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Description</th>
<th className="num">Amount</th>
<th>Frequency</th>
<th>Since</th>
<th>Category</th>
<th></th>
</tr>
</thead>
<tbody>
{recurringExpenses.map((r) => (
<tr key={r.id}>
<td>{r.description}{r.deductible ? ' ✓' : ''}</td>
<td className="num">${r.amount.toFixed(2)}</td>
<td className="text-sm">{r.frequency}</td>
<td className="text-sm text-muted">{r.startDate}</td>
<td className="text-sm text-muted">{r.category ?? '—'}</td>
<td>
<div className="flex gap-1">
<button className="btn btn-sm btn-ghost" onClick={() => setEditingRecurring(r)} title="Edit"></button>
<button className="btn btn-sm btn-ghost text-danger" onClick={() => setDeletingRecurring(r.id)} title="Delete"></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<Modal open={showRecurring} title="Add recurring expense" onClose={() => setShowRecurring(false)}>
<RecurringExpenseForm
onSubmit={(d) => { addRecurringExpense(d); setShowRecurring(false); }}
onCancel={() => setShowRecurring(false)}
/>
</Modal>
<Modal open={editingRecurring != null} title="Edit recurring expense" onClose={() => setEditingRecurring(null)}>
{editingRecurring && (
<RecurringExpenseForm
initial={editingRecurring}
onSubmit={(d) => { updateRecurringExpense(editingRecurring.id, d); setEditingRecurring(null); }}
onCancel={() => setEditingRecurring(null)}
/>
)}
</Modal>
<ConfirmDialog
open={deletingRecurring != null}
title="Delete recurring expense?"
message="This removes the template but keeps existing expense entries already created from it."
confirmLabel="Delete"
danger
onConfirm={() => { if (deletingRecurring) deleteRecurringExpense(deletingRecurring); setDeletingRecurring(null); }}
onCancel={() => setDeletingRecurring(null)}
/>
<TileConfigModal
open={configOpen}
tiles={tiles}
onChange={(t) => setPageTiles('expenses', t)}
onClose={() => setConfigOpen(false)}
/>
</>
);
}
// ─── Recurring expense form ───────────────────────────────────────────────────
type RecurringDraft = Omit<RecurringExpense, 'id' | 'createdAt' | 'updatedAt'>;
const FREQ_LABELS: Record<RecurringFrequency, string> = {
weekly: 'Weekly', biweekly: 'Bi-weekly', monthly: 'Monthly',
quarterly: 'Quarterly', annually: 'Annually',
};
function RecurringExpenseForm({
initial,
onSubmit,
onCancel,
}: {
initial?: Partial<RecurringDraft>;
onSubmit: (d: RecurringDraft) => void;
onCancel: () => void;
}) {
const [desc, setDesc] = useState(initial?.description ?? '');
const [amount, setAmount] = useState(initial?.amount?.toString() ?? '');
const [category, setCategory] = useState(initial?.category ?? '');
const [deductible, setDeductible] = useState(initial?.deductible ?? true);
const [frequency, setFrequency] = useState<RecurringFrequency>(initial?.frequency ?? 'monthly');
const [dayOfMonth, setDayOfMonth] = useState(initial?.dayOfMonth?.toString() ?? '1');
const [startDate, setStartDate] = useState(initial?.startDate ?? todayISO());
const [endDate, setEndDate] = useState(initial?.endDate ?? '');
const submit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
description: desc, amount: parseFloat(amount) || 0,
category: category || undefined, deductible, frequency,
dayOfMonth: parseInt(dayOfMonth) || 1,
startDate, endDate: endDate || null,
});
};
return (
<form onSubmit={submit} className="flex-col gap-3">
<div className="field">
<label>Description</label>
<input className="input" value={desc} onChange={(e) => setDesc(e.target.value)} required placeholder="e.g. Rent, Software subscription" />
</div>
<div className="field-row">
<div className="field">
<label>Amount ($)</label>
<input type="number" step="0.01" className="input" value={amount} onChange={(e) => setAmount(e.target.value)} required />
</div>
<div className="field">
<label>Frequency</label>
<select className="select" value={frequency} onChange={(e) => setFrequency(e.target.value as RecurringFrequency)}>
{(Object.keys(FREQ_LABELS) as RecurringFrequency[]).map((f) => (
<option key={f} value={f}>{FREQ_LABELS[f]}</option>
))}
</select>
</div>
</div>
{(frequency === 'monthly' || frequency === 'quarterly' || frequency === 'annually') && (
<div className="field">
<label>Day of month</label>
<input type="number" className="input" min={1} max={28} value={dayOfMonth} onChange={(e) => setDayOfMonth(e.target.value)} />
</div>
)}
<div className="field-row">
<div className="field">
<label>Start date</label>
<input type="date" className="input" value={startDate} onChange={(e) => setStartDate(e.target.value)} required />
</div>
<div className="field">
<label>End date (optional)</label>
<input type="date" className="input" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
</div>
<div className="field-row">
<div className="field">
<label>Category</label>
<input className="input" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="optional" />
</div>
<div className="field">
<label>&nbsp;</label>
<label className="checkbox">
<input type="checkbox" checked={deductible} onChange={(e) => setDeductible(e.target.checked)} />
Tax deductible
</label>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={onCancel}>Cancel</button>
<button type="submit" className="btn btn-primary">Save</button>
</div>
</form>
);
}
// ─── Period summary strip (configurable tiles) ───────────────────────────────
const LEDGER_TILE_LABELS: Record<LedgerTile, string> = {
ytd: 'YTD total',
avgMonth: 'Avg / month',
yearProj: 'Year projected',
thisMonth: 'This month',
avgDay: 'Avg / day',
monthProj: 'Month projected',
today: 'Today',
};
const ALL_LEDGER_TILES: LedgerTile[] = ['ytd', 'avgMonth', 'yearProj', 'thisMonth', 'avgDay', 'monthProj', 'today'];
function PeriodSummaryRow({
stats,
metric,
label,
tiles,
onConfigure,
}: {
stats: ReturnType<typeof aggregate>;
metric: 'workValue' | 'payments' | 'expenses';
label: string;
tiles: LedgerTile[];
onConfigure: () => void;
}) {
const now = new Date();
const currentYear = String(now.getFullYear());
const currentMonth = now.toISOString().slice(0, 7);
const today = now.toISOString().slice(0, 10);
const y = stats.years.find((x) => x.label === currentYear);
const m = stats.months.get(currentMonth);
const d = stats.days.get(today);
const yValue = y?.[metric] ?? 0;
const mValue = m?.[metric] ?? 0;
const monthsElapsed = now.getMonth() + 1;
const dayOfMonth = now.getDate();
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
const yearStart = new Date(now.getFullYear(), 0, 1).getTime();
const yearEnd = new Date(now.getFullYear() + 1, 0, 1).getTime();
const yearFrac = (now.getTime() - yearStart) / (yearEnd - yearStart);
const tileValues: Record<LedgerTile, number | null> = {
ytd: yValue,
avgMonth: yValue > 0 ? yValue / monthsElapsed : null,
yearProj: yValue > 0 && yearFrac > 0 && yearFrac < 1 ? yValue / yearFrac : null,
thisMonth: mValue,
avgDay: mValue > 0 ? mValue / dayOfMonth : null,
monthProj: mValue > 0 && dayOfMonth > 0 && dayOfMonth < daysInMonth
? (mValue / dayOfMonth) * daysInMonth
: null,
today: d?.[metric] ?? 0,
};
const tileDisplayLabel: Record<LedgerTile, string> = {
...LEDGER_TILE_LABELS,
ytd: `YTD ${label}`,
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 4 }}>
<button className="btn btn-sm btn-ghost" onClick={onConfigure}> Tiles</button>
</div>
<div className="stat-grid">
{tiles.map((t) => {
const value = tileValues[t];
if (value == null) return null;
return <StatTile key={t} label={tileDisplayLabel[t]} value={value} />;
})}
</div>
</div>
);
}
function StatTile({ label, value }: { label: string; value: number }) {
return (
<div className="stat-card">
<div className="stat-label">{label}</div>
<div className="stat-value">{fmtMoney(value)}</div>
</div>
);
}
// ─── Tile configure modal (shared by all ledger tabs) ────────────────────────
function TileConfigModal({
open,
tiles,
onChange,
onClose,
}: {
open: boolean;
tiles: LedgerTile[];
onChange: (tiles: LedgerTile[]) => void;
onClose: () => void;
}) {
const toggle = (t: LedgerTile) => {
onChange(tiles.includes(t) ? tiles.filter((x) => x !== t) : [...tiles, t]);
};
return (
<Modal open={open} title="Configure tiles" onClose={onClose} footer={
<button className="btn btn-primary" onClick={onClose}>Done</button>
}>
<div className="flex-col gap-2">
<p className="text-sm text-muted">Choose which tiles to display:</p>
{ALL_LEDGER_TILES.map((t) => (
<label key={t} className="checkbox">
<input type="checkbox" checked={tiles.includes(t)} onChange={() => toggle(t)} />
{LEDGER_TILE_LABELS[t]}
</label>
))}
</div>
</Modal>
);
}

214
src/pages/SettingsPage.tsx Normal file
View file

@ -0,0 +1,214 @@
/**
* Settings themes, default rate, and manual file import/export.
*/
import { useState } from 'react';
import { useAppStore } from '@/store/appStore';
import { THEME_NAMES } from '@/themes/ThemeProvider';
import type { ThemeName, ThemeMode } from '@/types';
import { isT99Encrypted } from '@/lib/storage/vault';
export function SettingsPage() {
const settings = useAppStore((s) => s.data.settings);
const setTheme = useAppStore((s) => s.setTheme);
const setDefaultRate = useAppStore((s) => s.setDefaultRate);
const exportFile = useAppStore((s) => s.exportFile);
const importFile = useAppStore((s) => s.importFile);
const [exportPwd, setExportPwd] = useState('');
const [importFileObj, setImportFileObj] = useState<File | null>(null);
const [importEncrypted, setImportEncrypted] = useState(false);
const [importPwd, setImportPwd] = useState('');
const handleExport = async () => {
try {
await exportFile(exportPwd || undefined);
setExportPwd('');
} catch (e) {
alert(`Export failed: ${e instanceof Error ? e.message : e}`);
}
};
const handleFileSelect = async (file: File | null) => {
setImportFileObj(file);
setImportPwd('');
if (!file) { setImportEncrypted(false); return; }
const content = await file.text();
setImportEncrypted(isT99Encrypted(content));
};
const handleImport = async () => {
if (!importFileObj) return;
try {
await importFile(importFileObj, importEncrypted ? importPwd : undefined);
alert('Import successful');
setImportFileObj(null);
setImportEncrypted(false);
setImportPwd('');
} catch (e) {
alert(`Import failed: ${e instanceof Error ? e.message : e}`);
}
};
return (
<div className="flex-col gap-4" style={{ maxWidth: 720, margin: '0 auto' }}>
<h1>Settings</h1>
{/* ─── Theme ─────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-header"><span className="card-title">Appearance</span></div>
<div className="field-row">
<div className="field">
<label>Theme</label>
<select
className="select"
value={settings.theme}
onChange={(e) => setTheme(e.target.value as ThemeName, settings.mode)}
>
{THEME_NAMES.map((t) => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
</div>
<div className="field">
<label>Mode</label>
<div className="btn-group">
{(['light', 'dark'] as ThemeMode[]).map((m) => (
<button
key={m}
className={`btn ${settings.mode === m ? 'active' : ''}`}
onClick={() => setTheme(settings.theme, m)}
>
{m === 'light' ? '☀ Light' : '🌙 Dark'}
</button>
))}
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8, marginTop: 12 }}>
{THEME_NAMES.map((t) => (
<ThemeSwatch
key={t.id}
theme={t.id}
mode={settings.mode}
label={t.label}
active={settings.theme === t.id}
onClick={() => setTheme(t.id, settings.mode)}
/>
))}
</div>
</div>
{/* ─── Defaults ──────────────────────────────────────────────────── */}
<div className="card">
<div className="card-header"><span className="card-title">Defaults</span></div>
<div className="field" style={{ maxWidth: 200 }}>
<label>Default hourly rate ($/hr)</label>
<input
type="number"
step="0.01"
className="input"
value={settings.defaultRate}
onChange={(e) => setDefaultRate(parseFloat(e.target.value) || 0)}
/>
</div>
</div>
{/* ─── Import / Export ───────────────────────────────────────────── */}
<div className="card">
<div className="card-header"><span className="card-title">Backup &amp; Restore</span></div>
<div className="flex-col gap-2 mb-4">
<p className="text-sm text-muted">
Export a <code>.t99</code> backup file. Leave the password blank to export
as plain JSON, or enter a password to encrypt the file.
</p>
<div className="flex gap-2 items-end">
<div className="field" style={{ flex: 1 }}>
<label>Password <span className="text-muted">(optional)</span></label>
<input
type="password"
className="input"
value={exportPwd}
onChange={(e) => setExportPwd(e.target.value)}
placeholder="Leave blank for unencrypted export"
/>
</div>
<button className="btn" onClick={handleExport}>
Export backup
</button>
</div>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '8px 0' }} />
<div className="flex-col gap-2 mt-4">
<p className="text-sm text-muted">
Restore from a previously exported <code>.t99</code> file.
This replaces all current data.
</p>
<div className="field">
<label>Backup file</label>
<input
type="file"
accept=".t99"
onChange={(e) => handleFileSelect(e.target.files?.[0] ?? null)}
/>
</div>
{importEncrypted && (
<div className="field">
<label>File password</label>
<input
type="password"
className="input"
value={importPwd}
onChange={(e) => setImportPwd(e.target.value)}
autoFocus
placeholder="This file is encrypted — enter the password"
/>
</div>
)}
<button
className="btn btn-primary"
onClick={handleImport}
disabled={!importFileObj || (importEncrypted && !importPwd)}
>
Restore from backup
</button>
</div>
</div>
</div>
);
}
function ThemeSwatch({
theme, mode, label, active, onClick,
}: {
theme: ThemeName; mode: ThemeMode; label: string; active: boolean; onClick: () => void;
}) {
return (
<div
data-theme={theme}
data-mode={mode}
onClick={onClick}
style={{
padding: 10,
borderRadius: 6,
cursor: 'pointer',
border: active ? '2px solid var(--accent)' : '1px solid var(--border)',
background: 'var(--bg)',
color: 'var(--fg)',
}}
>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 12, fontWeight: 600 }}>{label}</div>
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
<div style={{ width: 16, height: 16, borderRadius: 4, background: 'var(--accent)' }} />
<div style={{ width: 16, height: 16, borderRadius: 4, background: 'var(--success)' }} />
<div style={{ width: 16, height: 16, borderRadius: 4, background: 'var(--warning)' }} />
<div style={{ width: 16, height: 16, borderRadius: 4, background: 'var(--danger)' }} />
</div>
</div>
);
}

458
src/pages/TaxPage.tsx Normal file
View file

@ -0,0 +1,458 @@
/**
* Tax page calculations, projections, quarterly schedule,
* and highlighted prompts for missing inputs.
*/
import React, { useMemo, useState } from 'react';
import { useAppStore } from '@/store/appStore';
import { calculateTax } from '@/lib/tax/calculate';
import { availableTaxYears } from '@/lib/tax/brackets';
import type { FilingStatus, TaxInputs, TaxTile } from '@/types';
import { ChartSidebar } from '@/components/charts/ChartSidebar';
import { Modal } from '@/components/common/Modal';
import { fmtMoney, fmtMoneyShort, todayISO } from '@/lib/format';
const FILING_LABELS: Record<FilingStatus, string> = {
single: 'Single',
mfj: 'Married filing jointly',
mfs: 'Married filing separately',
hoh: 'Head of household',
};
const ALL_TAX_TILES: TaxTile[] = [
'grossReceipts', 'deductibleExpenses', 'netProfit',
'seTaxableBase', 'socialSecurityTax', 'medicareTax', 'additionalMedicareTax', 'totalSETax',
'seTaxDeduction', 'qbiDeduction', 'standardDeduction',
'agi', 'taxableIncome', 'federalIncomeTax', 'totalFederalTax',
'alreadyPaid', 'remainingDue',
];
const TAX_TILE_LABELS: Record<TaxTile, string> = {
grossReceipts: 'Gross Receipts',
deductibleExpenses: 'Deductible Expenses',
netProfit: 'Net Profit',
seTaxableBase: 'SE Taxable Base',
socialSecurityTax: 'Social Security Tax',
medicareTax: 'Medicare Tax',
additionalMedicareTax: 'Additional Medicare Tax',
totalSETax: 'Total SE Tax',
seTaxDeduction: 'SE Tax Deduction',
qbiDeduction: 'QBI Deduction (§199A)',
standardDeduction: 'Standard Deduction',
agi: 'Adjusted Gross Income',
taxableIncome: 'Taxable Income',
federalIncomeTax: 'Federal Income Tax',
totalFederalTax: 'Total Federal Tax',
alreadyPaid: 'Already Paid',
remainingDue: 'Remaining Due',
};
// Tiles that represent money owed (red), vs money saved/paid (green)
const TAX_TILE_NEGATIVE = new Set<TaxTile>([
'totalFederalTax', 'federalIncomeTax', 'totalSETax',
'socialSecurityTax', 'medicareTax', 'additionalMedicareTax', 'remainingDue',
]);
const TAX_TILE_POSITIVE = new Set<TaxTile>(['alreadyPaid']);
export function TaxPage() {
const payments = useAppStore((s) => s.data.payments);
const expenses = useAppStore((s) => s.data.expenses);
const taxInputs = useAppStore((s) => s.data.taxInputs);
const setTaxInputs = useAppStore((s) => s.setTaxInputs);
const addTaxPayment = useAppStore((s) => s.addTaxPayment);
const deleteTaxPayment = useAppStore((s) => s.deleteTaxPayment);
const taxTiles = useAppStore((s) => s.data.dashboard.taxTiles);
const setPageTiles = useAppStore((s) => s.setPageTiles);
const years = availableTaxYears();
const [year, setYear] = useState(years[0] ?? new Date().getFullYear());
const [tilesConfigOpen, setTilesConfigOpen] = useState(false);
const [newPaymentDate, setNewPaymentDate] = useState(todayISO());
const [newPaymentAmount, setNewPaymentAmount] = useState('');
const [newPaymentQuarter, setNewPaymentQuarter] = useState<string>('');
const [newPaymentNote, setNewPaymentNote] = useState('');
const inputs: TaxInputs = taxInputs[year] ?? { taxYear: year, filingStatus: 'single' };
// Auto-populate prior year data from stored tax results if not manually entered
const prevYearInputs = taxInputs[year - 1];
const prevYearResult = useMemo(
() => prevYearInputs ? calculateTax(payments, expenses, prevYearInputs, { project: false }) : null,
[payments, expenses, prevYearInputs],
);
const effectiveInputs: TaxInputs = useMemo(() => ({
...inputs,
priorYearTax: inputs.priorYearTax ?? prevYearResult?.totalFederalTax,
priorYearAGI: inputs.priorYearAGI ?? prevYearResult?.agi,
}), [inputs, prevYearResult]);
const isCurrentYear = year === new Date().getFullYear();
const result = useMemo(
() => calculateTax(payments, expenses, effectiveInputs, { project: false }),
[payments, expenses, effectiveInputs],
);
const projResult = useMemo(
() => isCurrentYear ? calculateTax(payments, expenses, effectiveInputs, { project: true }) : null,
[payments, expenses, effectiveInputs, isCurrentYear],
);
const updateInput = (patch: Partial<TaxInputs>) => setTaxInputs(year, patch);
const submitPayment = (e: React.FormEvent) => {
e.preventDefault();
if (!newPaymentAmount) return;
addTaxPayment(year, {
date: newPaymentDate,
amount: parseFloat(newPaymentAmount),
quarter: newPaymentQuarter ? (parseInt(newPaymentQuarter) as 1|2|3|4) : undefined,
note: newPaymentNote || undefined,
});
setNewPaymentAmount('');
setNewPaymentNote('');
};
return (
<div className="split-layout">
<div className="left">
{/* ─── Year / filing status / projection toggle ───────────────── */}
<div className="card">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
<span className="text-muted text-sm">Tax Year</span>
<select
className="select"
style={{ width: 120 }}
value={year}
onChange={(e) => setYear(Number(e.target.value))}
>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-muted text-sm">Filing Status</span>
<select
className="select"
style={{ width: 200 }}
value={inputs.filingStatus}
onChange={(e) => updateInput({ filingStatus: e.target.value as FilingStatus })}
>
{(Object.keys(FILING_LABELS) as FilingStatus[]).map((s) => (
<option key={s} value={s}>{FILING_LABELS[s]}</option>
))}
</select>
</div>
{isCurrentYear && (
<span className="text-sm text-muted">Projected full-year values shown alongside actuals</span>
)}
</div>
</div>
{/* ─── Highlighted prompts for missing data ───────────────────── */}
{result.prompts.length > 0 && (
<div className="flex-col gap-2">
{result.prompts.map((p) => {
const autoValue = effectiveInputs[p.field];
const isAutoFilled = autoValue != null && inputs[p.field] == null;
return (
<div key={p.field} className="field-prompt">
<div className="field">
<label>
{p.label}
{isAutoFilled && <span className="text-muted text-sm"> (auto from {year - 1})</span>}
</label>
<input
type="number"
step="0.01"
className="input"
placeholder={isAutoFilled ? String(autoValue) : 'Enter value...'}
value={inputs[p.field] ?? ''}
onChange={(e) =>
updateInput({ [p.field]: e.target.value ? Number(e.target.value) : undefined })
}
/>
</div>
<div className="prompt-reason">{p.reason}</div>
</div>
);
})}
</div>
)}
{/* ─── Configurable summary tiles ──────────────────────────────── */}
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 4 }}>
<button className="btn btn-sm btn-ghost" onClick={() => setTilesConfigOpen(true)}> Tiles</button>
</div>
<div className="stat-grid">
{taxTiles.map((t) => {
const value: number = (result as unknown as Record<string, number>)[t] ?? 0;
const projValue: number | undefined = projResult
? (projResult as unknown as Record<string, number>)[t]
: undefined;
const className = TAX_TILE_NEGATIVE.has(t) ? 'negative' : TAX_TILE_POSITIVE.has(t) ? 'positive' : '';
const sub = t === 'remainingDue'
? (result.safeHarborMet === true ? '✓ Safe harbor met'
: result.safeHarborMet === false ? '⚠ Safe harbor NOT met'
: 'Safe harbor: need prior year data')
: t === 'alreadyPaid' ? 'Withholding + estimates'
: undefined;
return (
<div key={t} className={`stat-card ${className}`}>
<div className="stat-label">{TAX_TILE_LABELS[t]}</div>
<div className="stat-value">{fmtMoneyShort(value)}</div>
{projValue !== undefined && (
<div className="stat-sub">projected: {fmtMoneyShort(projValue)}</div>
)}
{sub && !projValue && <div className="stat-sub">{sub}</div>}
</div>
);
})}
</div>
</div>
<Modal
open={tilesConfigOpen}
title="Configure tax tiles"
onClose={() => setTilesConfigOpen(false)}
footer={<button className="btn btn-primary" onClick={() => setTilesConfigOpen(false)}>Done</button>}
>
<div className="flex-col gap-2">
<p className="text-sm text-muted">Choose which values to display as tiles:</p>
{ALL_TAX_TILES.map((t) => (
<label key={t} className="checkbox">
<input
type="checkbox"
checked={taxTiles.includes(t)}
onChange={() => {
const next = taxTiles.includes(t)
? taxTiles.filter((x) => x !== t)
: [...taxTiles, t];
setPageTiles('tax', next);
}}
/>
{TAX_TILE_LABELS[t]}
</label>
))}
</div>
</Modal>
{/* ─── Quarterly schedule ─────────────────────────────────────── */}
<div className="card">
<div className="card-header">
<span className="card-title">Quarterly Estimated Payments</span>
{result.safeHarborAmount != null && (
<span className="text-sm text-muted">
Safe harbor: {fmtMoney(result.safeHarborAmount)}/yr
</span>
)}
</div>
<table className="data-table">
<thead>
<tr>
<th>Q</th>
<th>Due</th>
<th className="num">Per-quarter (actual)</th>
{projResult && <th className="num" style={{ color: 'var(--fg-muted)' }}>Per-quarter (proj)</th>}
<th className="num">Remaining / Q</th>
<th className="num">Safe Harbor Min</th>
<th></th>
</tr>
</thead>
<tbody>
{result.quarterlySchedule.map((q, i) => (
<tr key={q.quarter}>
<td>Q{q.quarter}</td>
<td>{q.dueDate}</td>
<td className="num">{fmtMoney(q.projectedAmount)}</td>
{projResult && (
<td className="num" style={{ color: 'var(--fg-muted)' }}>
{fmtMoney(projResult.quarterlySchedule[i]?.projectedAmount)}
</td>
)}
<td className="num" style={{ fontWeight: q.isPastDue ? 400 : 600 }}>{fmtMoney(q.remainingAmount)}</td>
<td className="num">{q.safeHarborAmount != null ? fmtMoney(q.safeHarborAmount) : '—'}</td>
<td>{q.isPastDue && <span className="text-danger text-sm">past due</span>}</td>
</tr>
))}
</tbody>
</table>
{prevYearResult && !inputs.priorYearTax && (
<div className="mt-2 text-sm text-muted">
Prior year data auto-loaded from {year - 1} records (tax: {fmtMoney(prevYearResult.totalFederalTax)}, AGI: {fmtMoney(prevYearResult.agi)})
</div>
)}
</div>
{/* ─── Tax payment log ─────────────────────────────────────────── */}
<div className="card">
<div className="card-header"><span className="card-title">Estimated Tax Payments Made</span></div>
{(inputs.taxPayments ?? []).length > 0 && (
<table className="data-table" style={{ marginBottom: 12 }}>
<thead>
<tr>
<th>Date</th>
<th>Q</th>
<th className="num">Amount</th>
<th>Note</th>
<th></th>
</tr>
</thead>
<tbody>
{(inputs.taxPayments ?? []).map((p) => (
<tr key={p.id}>
<td>{p.date}</td>
<td>{p.quarter ? `Q${p.quarter}` : '—'}</td>
<td className="num">{fmtMoney(p.amount)}</td>
<td className="text-muted text-sm">{p.note ?? ''}</td>
<td>
<button
className="btn btn-sm btn-ghost text-danger"
onClick={() => deleteTaxPayment(year, p.id)}
title="Delete"
></button>
</td>
</tr>
))}
<tr style={{ fontWeight: 600 }}>
<td colSpan={2}>Total logged</td>
<td className="num">{fmtMoney((inputs.taxPayments ?? []).reduce((s, p) => s + p.amount, 0))}</td>
<td colSpan={2}></td>
</tr>
</tbody>
</table>
)}
<form onSubmit={submitPayment} className="flex items-center gap-2 flex-wrap">
<input
type="date" className="input" style={{ width: 148 }}
value={newPaymentDate} onChange={(e) => setNewPaymentDate(e.target.value)} required
/>
<select
className="select" style={{ width: 90 }}
value={newPaymentQuarter} onChange={(e) => setNewPaymentQuarter(e.target.value)}
>
<option value="">Q?</option>
<option value="1">Q1</option>
<option value="2">Q2</option>
<option value="3">Q3</option>
<option value="4">Q4</option>
</select>
<input
type="number" step="0.01" className="input" style={{ width: 120 }}
placeholder="Amount" value={newPaymentAmount}
onChange={(e) => setNewPaymentAmount(e.target.value)} required
/>
<input
className="input" style={{ flex: 1, minWidth: 100 }}
placeholder="Note (optional)" value={newPaymentNote}
onChange={(e) => setNewPaymentNote(e.target.value)}
/>
<button type="submit" className="btn btn-primary btn-sm">+ Log payment</button>
</form>
</div>
{/* ─── Full breakdown ──────────────────────────────────────────── */}
<div className="card">
<div className="card-header"><span className="card-title">Calculation Breakdown</span></div>
<table className="data-table">
<thead>
<tr>
<th></th>
<th className="num">Actual YTD</th>
{projResult && <th className="num" style={{ color: 'var(--fg-muted)' }}>Projected</th>}
</tr>
</thead>
<tbody>
<BreakdownRow label="Gross receipts (payments)" value={result.grossReceipts} proj={projResult?.grossReceipts} />
<BreakdownRow label=" Deductible expenses" value={-result.deductibleExpenses} proj={projResult ? -projResult.deductibleExpenses : undefined} />
<BreakdownRow label="= Net profit (Schedule C)" value={result.netProfit} proj={projResult?.netProfit} bold />
<BreakdownSep cols={projResult ? 3 : 2} />
<BreakdownRow label="SE taxable base (× 92.35%)" value={result.seTaxableBase} proj={projResult?.seTaxableBase} />
<BreakdownRow label="Social Security tax (12.4%, capped)" value={result.socialSecurityTax} proj={projResult?.socialSecurityTax} />
<BreakdownRow label="Medicare tax (2.9%)" value={result.medicareTax} proj={projResult?.medicareTax} />
<BreakdownRow label="Additional Medicare (0.9%)" value={result.additionalMedicareTax} proj={projResult?.additionalMedicareTax} />
<BreakdownRow label="= Total SE tax" value={result.totalSETax} proj={projResult?.totalSETax} bold />
<BreakdownSep cols={projResult ? 3 : 2} />
<BreakdownRow label=" ½ SE tax deduction" value={-result.seTaxDeduction} proj={projResult ? -projResult.seTaxDeduction : undefined} />
<BreakdownRow label=" Standard deduction" value={-result.standardDeduction} proj={projResult ? -projResult.standardDeduction : undefined} />
<BreakdownRow label=" QBI deduction (§199A)" value={-result.qbiDeduction} proj={projResult ? -projResult.qbiDeduction : undefined} />
<BreakdownRow label="= Taxable income" value={result.taxableIncome} proj={projResult?.taxableIncome} bold />
<BreakdownRow label="Federal income tax" value={result.federalIncomeTax} proj={projResult?.federalIncomeTax} bold />
<BreakdownSep cols={projResult ? 3 : 2} />
<BreakdownRow label="TOTAL FEDERAL TAX" value={result.totalFederalTax} proj={projResult?.totalFederalTax} bold highlight />
</tbody>
</table>
{(result.notes.length > 0 || (projResult?.notes.length ?? 0) > 0) && (
<div className="mt-4 text-sm text-muted">
{result.notes.map((n, i) => <div key={i}> {n}</div>)}
</div>
)}
<div className="mt-4 text-sm text-muted">
This is a planning estimator, not tax advice. Consult a tax professional.
</div>
</div>
{/* ─── Additional inputs ───────────────────────────────────────── */}
<div className="card">
<div className="card-header"><span className="card-title">Additional Inputs</span></div>
<div className="field-row">
<div className="field">
<label>W-2 wages</label>
<input type="number" step="0.01" className="input" value={inputs.w2Wages ?? ''}
onChange={(e) => updateInput({ w2Wages: e.target.value ? Number(e.target.value) : undefined })} />
</div>
<div className="field">
<label>Federal withholding</label>
<input type="number" step="0.01" className="input" value={inputs.federalWithholding ?? ''}
onChange={(e) => updateInput({ federalWithholding: e.target.value ? Number(e.target.value) : undefined })} />
</div>
<div className="field">
<label>Est. payments made</label>
<input type="number" step="0.01" className="input" value={inputs.estimatedPaymentsMade ?? ''}
onChange={(e) => updateInput({ estimatedPaymentsMade: e.target.value ? Number(e.target.value) : undefined })} />
</div>
</div>
<div className="field-row mt-2">
<div className="field">
<label>Prior year AGI</label>
<input type="number" step="0.01" className="input" value={inputs.priorYearAGI ?? ''}
onChange={(e) => updateInput({ priorYearAGI: e.target.value ? Number(e.target.value) : undefined })} />
</div>
<div className="field">
<label>Prior year tax</label>
<input type="number" step="0.01" className="input" value={inputs.priorYearTax ?? ''}
onChange={(e) => updateInput({ priorYearTax: e.target.value ? Number(e.target.value) : undefined })} />
</div>
</div>
</div>
</div>
<div className="right">
<ChartSidebar tab="tax" />
</div>
</div>
);
}
function BreakdownRow({ label, value, proj, bold, highlight }: {
label: string; value: number; proj?: number; bold?: boolean; highlight?: boolean;
}) {
return (
<tr style={{ fontWeight: bold ? 600 : 400, background: highlight ? 'var(--accent-muted)' : undefined }}>
<td>{label}</td>
<td className="num">{fmtMoney(value)}</td>
{proj !== undefined && (
<td className="num" style={{ color: 'var(--fg-muted)' }}>{fmtMoney(proj)}</td>
)}
</tr>
);
}
function BreakdownSep({ cols = 2 }: { cols?: number }) {
return <tr><td colSpan={cols} style={{ padding: 2, background: 'var(--bg-elev-2)' }}></td></tr>;
}

373
src/pages/TimerPage.tsx Normal file
View file

@ -0,0 +1,373 @@
/**
* Live Work Timer start / pause / split with crash recovery.
*/
import { useState } from 'react';
import { useTimerStore } from '@/store/timerStore';
import { useAppStore } from '@/store/appStore';
import type { TimerSplit } from '@/types';
import {
fmtDuration,
fmtDurationVerbose,
fmtMoney,
totalMinutes,
msToHours,
todayISO,
} from '@/lib/format';
import { Modal, ConfirmDialog } from '@/components/common/Modal';
export function TimerPage() {
const t = useTimerStore();
const addWorkEntry = useAppStore((s) => s.addWorkEntry);
const defaultRate = useAppStore((s) => s.data.settings.defaultRate);
const [editSplit, setEditSplit] = useState<TimerSplit | null>(null);
const [editDraft, setEditDraft] = useState<Partial<TimerSplit>>({});
const [confirmEdit, setConfirmEdit] = useState(false);
const [recordSplit, setRecordSplit] = useState<TimerSplit | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const liveEarned = msToHours(t.elapsedMs) * t.currentRate;
const totalSplitMs = t.splits.reduce((s, x) => s + x.elapsedMs, 0);
const totalSplitEarned = t.splits.reduce(
(s, x) => s + msToHours(x.elapsedMs) * x.rate,
0,
);
// ─── Edit flow: two-step confirm to prevent accidents ──────────────────────
const openEdit = (sp: TimerSplit) => {
setEditSplit(sp);
setEditDraft({
label: sp.label,
elapsedMs: sp.elapsedMs,
rate: sp.rate,
});
};
const stageEdit = () => setConfirmEdit(true);
const commitEdit = () => {
if (!editSplit) return;
t.updateSplit(editSplit.id, editDraft);
setConfirmEdit(false);
setEditSplit(null);
setEditDraft({});
};
// ─── Record-to-work-log flow ───────────────────────────────────────────────
const [recordMode, setRecordMode] = useState<'item' | 'daily'>('item');
const [recordDate, setRecordDate] = useState(todayISO());
const [recordDesc, setRecordDesc] = useState('');
const openRecord = (sp: TimerSplit) => {
setRecordSplit(sp);
setRecordDate(todayISO());
setRecordDesc(sp.label ?? `Timer split (${fmtDurationVerbose(sp.elapsedMs)})`);
setRecordMode('item');
};
const commitRecord = () => {
if (!recordSplit) return;
const hours = msToHours(recordSplit.elapsedMs);
const entry = addWorkEntry({
date: recordDate,
description:
recordMode === 'daily'
? `[Daily timer] ${recordDesc}`
: recordDesc,
hours,
rate: recordSplit.rate,
});
t.markRecorded(recordSplit.id, entry.id);
setRecordSplit(null);
};
// ─── Render ────────────────────────────────────────────────────────────────
return (
<div className="flex-col gap-4">
{/* ─── Crash recovery banner ───────────────────────────────────────── */}
{t.crashRecovery && (
<div className="crash-banner">
<strong> Timer recovered from unexpected close</strong>
<div className="crash-grid">
<span>Crash / close time:</span>
<span>{new Date(t.crashRecovery.crashTime).toLocaleString()}</span>
<span>Reload time:</span>
<span>{new Date(t.crashRecovery.reloadTime).toLocaleString()}</span>
<span>Gap:</span>
<span>{fmtDurationVerbose(t.crashRecovery.gapMs)}</span>
</div>
<div className="text-sm">
The clock kept running based on the original start time. If you
weren't actually working during the gap, subtract it below.
</div>
<div className="crash-actions">
<button className="btn btn-sm btn-danger" onClick={t.subtractCrashGap}>
Subtract {fmtDuration(t.crashRecovery.gapMs)} from clock
</button>
<button className="btn btn-sm" onClick={t.dismissCrashBanner}>
Keep I was working
</button>
</div>
</div>
)}
{/* ─── Live clock ──────────────────────────────────────────────────── */}
<div className="card">
<div className="timer-display">{fmtDuration(t.elapsedMs)}</div>
<div className="timer-earned">{fmtMoney(liveEarned)}</div>
<div className="flex items-center gap-2 mt-2" style={{ justifyContent: 'center' }}>
<span className="text-muted text-sm">Rate $/hr</span>
<input
type="number"
step="0.01"
className="input"
style={{ width: 100 }}
value={t.currentRate}
onChange={(e) => t.setRate(parseFloat(e.target.value) || 0)}
/>
<button
className="btn btn-sm btn-ghost"
onClick={() => t.setRate(defaultRate)}
title="Reset to default rate"
>
</button>
</div>
<div className="timer-controls">
{!t.running ? (
<button className="btn btn-primary btn-lg" onClick={t.start}>
Start
</button>
) : (
<button className="btn btn-lg" onClick={t.pause}>
Pause
</button>
)}
<button
className="btn btn-lg"
onClick={() => t.split()}
disabled={t.elapsedMs === 0}
>
Split
</button>
<button
className="btn btn-lg text-danger"
onClick={t.reset}
disabled={t.elapsedMs === 0 && t.splits.length === 0}
>
Reset
</button>
</div>
</div>
{/* ─── Splits table ────────────────────────────────────────────────── */}
<div className="card scroll-y" style={{ flex: 1, minHeight: 0 }}>
<div className="card-header">
<span className="card-title">Splits</span>
</div>
<table className="data-table">
<thead>
<tr>
<th>Label</th>
<th className="num">Time (h:m:s)</th>
<th className="num col-hide-sm">Minutes</th>
<th className="num">Rate</th>
<th className="num">Earned</th>
<th style={{ width: 40 }}></th>
<th style={{ width: 120 }}></th>
</tr>
</thead>
<tbody>
{t.splits.length === 0 && (
<tr>
<td colSpan={7} className="text-muted" style={{ textAlign: 'center', padding: 20 }}>
No splits yet. Press <strong>Split</strong> to record the current clock segment.
</td>
</tr>
)}
{t.splits.map((sp) => {
const earned = msToHours(sp.elapsedMs) * sp.rate;
return (
<tr key={sp.id}>
<td>{sp.label ?? <span className="text-muted"></span>}</td>
<td className="num mono">{fmtDurationVerbose(sp.elapsedMs)}</td>
<td className="num mono col-hide-sm">{totalMinutes(sp.elapsedMs)}</td>
<td className="num mono">{fmtMoney(sp.rate)}</td>
<td className="num mono">{fmtMoney(earned)}</td>
<td>
{sp.recorded && <span className="recorded-badge"> Logged</span>}
</td>
<td>
<div className="flex gap-1">
{!sp.recorded && (
<button
className="btn btn-sm btn-primary"
onClick={() => openRecord(sp)}
title="Record to work log"
>
Log
</button>
)}
<button className="btn btn-sm btn-ghost" onClick={() => openEdit(sp)} title="Edit">
</button>
<button
className="btn btn-sm btn-ghost text-danger"
onClick={() => setConfirmDeleteId(sp.id)}
title="Delete"
>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td className="num mono">{fmtDurationVerbose(totalSplitMs)}</td>
<td className="num mono col-hide-sm">{totalMinutes(totalSplitMs)}</td>
<td></td>
<td className="num mono">{fmtMoney(totalSplitEarned)}</td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
{/* ─── Edit split modal ────────────────────────────────────────────── */}
<Modal
open={editSplit != null && !confirmEdit}
title="Edit split"
onClose={() => setEditSplit(null)}
footer={
<>
<button className="btn" onClick={() => setEditSplit(null)}>Cancel</button>
<button className="btn btn-primary" onClick={stageEdit}>Save</button>
</>
}
>
<div className="flex-col gap-3">
<div className="field">
<label>Label</label>
<input
className="input"
value={editDraft.label ?? ''}
onChange={(e) => setEditDraft({ ...editDraft, label: e.target.value })}
/>
</div>
<div className="field-row">
<div className="field">
<label>Minutes</label>
<input
type="number"
className="input"
value={editDraft.elapsedMs != null ? Math.round(editDraft.elapsedMs / 60000) : ''}
onChange={(e) =>
setEditDraft({ ...editDraft, elapsedMs: (parseInt(e.target.value) || 0) * 60000 })
}
/>
</div>
<div className="field">
<label>Rate ($/hr)</label>
<input
type="number"
step="0.01"
className="input"
value={editDraft.rate ?? ''}
onChange={(e) => setEditDraft({ ...editDraft, rate: parseFloat(e.target.value) || 0 })}
/>
</div>
</div>
</div>
</Modal>
<ConfirmDialog
open={confirmEdit}
title="Confirm edit"
message="Are you sure you want to change this split? This can't be undone."
confirmLabel="Yes, save changes"
onConfirm={commitEdit}
onCancel={() => setConfirmEdit(false)}
/>
{/* ─── Record-to-log modal ─────────────────────────────────────────── */}
<Modal
open={recordSplit != null}
title="Record to work log"
onClose={() => setRecordSplit(null)}
footer={
<>
<button className="btn" onClick={() => setRecordSplit(null)}>Cancel</button>
<button className="btn btn-primary" onClick={commitRecord}>Record</button>
</>
}
>
{recordSplit && (
<div className="flex-col gap-3">
<div className="btn-group">
<button
className={`btn btn-sm ${recordMode === 'item' ? 'active' : ''}`}
onClick={() => setRecordMode('item')}
>
As individual item
</button>
<button
className={`btn btn-sm ${recordMode === 'daily' ? 'active' : ''}`}
onClick={() => setRecordMode('daily')}
>
Add to day total
</button>
</div>
<div className="field">
<label>Date</label>
<input
type="date"
className="input"
value={recordDate}
onChange={(e) => setRecordDate(e.target.value)}
/>
</div>
<div className="field">
<label>Description</label>
<input
className="input"
value={recordDesc}
onChange={(e) => setRecordDesc(e.target.value)}
/>
</div>
<div className="text-sm text-muted">
Will log <span className="mono">{fmtDurationVerbose(recordSplit.elapsedMs)}</span> @{' '}
<span className="mono">{fmtMoney(recordSplit.rate)}/hr</span> ={' '}
<span className="mono">{fmtMoney(msToHours(recordSplit.elapsedMs) * recordSplit.rate)}</span>
</div>
</div>
)}
</Modal>
{/* ─── Delete confirm ─────────────────────────────────────────────── */}
<ConfirmDialog
open={confirmDeleteId != null}
title="Delete split?"
message="This will permanently remove this split from the table."
confirmLabel="Delete"
danger
onConfirm={() => {
if (confirmDeleteId) t.deleteSplit(confirmDeleteId);
setConfirmDeleteId(null);
}}
onCancel={() => setConfirmDeleteId(null)}
/>
</div>
);
}

538
src/store/appStore.ts Normal file
View file

@ -0,0 +1,538 @@
/**
* Zustand store single source of truth for all app data.
* Persistence is manual (we call vault.save() after mutations) so we can
* route through the encryption layer.
*
* Storage is always cookie-based. A device-local key (stored in localStorage)
* is used as the encryption password no user password required.
*/
import { create } from 'zustand';
import type {
AppData,
WorkEntry,
Payment,
Expense,
RecurringExpense,
TaxInputs,
TaxPaymentRecord,
Settings,
DashboardConfig,
ChartConfig,
LedgerTile,
TaxTile,
ThemeName,
ThemeMode,
} from '@/types';
import { uid } from '@/lib/id';
import { Vault, deserializeT99 } from '@/lib/storage/vault';
import { todayISO } from '@/lib/format';
// ─── Defaults ────────────────────────────────────────────────────────────────
const defaultLedgerChart = (metric: 'workValue' | 'payments' | 'expenses', title: string, rollingAvgWindow?: number): ChartConfig => ({
id: uid(),
type: 'area',
metrics: [metric],
granularity: 'day',
rangeStart: null,
rangeEnd: null,
yMin: null,
yMax: null,
title,
rollingAvgWindow: rollingAvgWindow ?? null,
});
const defaultDashboard = (): DashboardConfig => ({
charts: [
{
id: uid(),
type: 'area',
metrics: ['workValue', 'payments', 'expenses'],
granularity: 'day',
rangeStart: null,
rangeEnd: null,
yMin: null,
yMax: null,
title: 'Work, Income & Expenses',
},
],
widgets: [
'ytdWorkValue',
'ytdWorkProj',
'ytdPayments',
'ytdPaymentsProj',
'ytdExpenses',
'ytdNet',
'ytdNetProj',
'nextQuarterlyDue',
'projectedAnnualTax',
'ytdActualTax',
'taxRemainingDue',
],
workCharts: [defaultLedgerChart('workValue', 'Work Value by Day', 10)],
paymentsCharts: [defaultLedgerChart('payments', 'Payments by Day')],
expensesCharts: [defaultLedgerChart('expenses', 'Expenses by Day')],
taxCharts: [],
workTiles: ['ytd', 'avgMonth', 'yearProj', 'thisMonth', 'avgDay', 'monthProj', 'today'] as LedgerTile[],
paymentsTiles: ['ytd', 'avgMonth', 'yearProj', 'thisMonth', 'avgDay', 'monthProj', 'today'] as LedgerTile[],
expensesTiles: ['ytd', 'avgMonth', 'yearProj', 'thisMonth', 'avgDay', 'monthProj', 'today'] as LedgerTile[],
taxTiles: ['totalFederalTax', 'alreadyPaid', 'remainingDue'] as TaxTile[],
});
const defaultSettings = (): Settings => ({
theme: 'standard',
mode: 'dark',
defaultRate: 50,
});
const defaultData = (): AppData => ({
workEntries: [],
payments: [],
expenses: [],
recurringExpenses: [],
taxInputs: {},
dashboard: defaultDashboard(),
settings: defaultSettings(),
version: 1,
});
// ─── Recurring expense helper ─────────────────────────────────────────────────
function generateOccurrences(re: RecurringExpense, upTo: string): string[] {
const results: string[] = [];
const maxDate = new Date(upTo + 'T00:00:00');
const endDate = re.endDate ? new Date(re.endDate + 'T00:00:00') : maxDate;
const limit = endDate < maxDate ? endDate : maxDate;
let cur = new Date(re.startDate + 'T00:00:00');
let safety = 1000;
while (cur <= limit && safety-- > 0) {
results.push(cur.toISOString().slice(0, 10));
switch (re.frequency) {
case 'weekly':
cur.setDate(cur.getDate() + 7);
break;
case 'biweekly':
cur.setDate(cur.getDate() + 14);
break;
case 'monthly': {
const y = cur.getMonth() === 11 ? cur.getFullYear() + 1 : cur.getFullYear();
const m = (cur.getMonth() + 1) % 12;
const maxDay = new Date(y, m + 1, 0).getDate();
cur = new Date(y, m, Math.min(re.dayOfMonth, maxDay));
break;
}
case 'quarterly': {
const totalMonths = cur.getMonth() + 3;
const y = cur.getFullYear() + Math.floor(totalMonths / 12);
const m = totalMonths % 12;
const maxDay = new Date(y, m + 1, 0).getDate();
cur = new Date(y, m, Math.min(re.dayOfMonth, maxDay));
break;
}
case 'annually':
cur = new Date(cur.getFullYear() + 1, cur.getMonth(), cur.getDate());
break;
}
}
return results;
}
// ─── Anonymous vault helper ───────────────────────────────────────────────────
function getDeviceKey(): string {
const stored = localStorage.getItem('t99_device_key');
if (stored) return stored;
const key = crypto.randomUUID();
localStorage.setItem('t99_device_key', key);
return key;
}
// ─── Store shape ─────────────────────────────────────────────────────────────
interface AppStore {
// Data
data: AppData;
// Internals
vault: Vault | null;
ready: boolean;
saving: boolean;
lastSaveError: string | null;
// ─── Init ─────────────────────────────────────────────────────────────────
/** Boot the anonymous cookie vault. Called once on app mount. */
init: () => Promise<void>;
// ─── CRUD: Work ───────────────────────────────────────────────────────────
addWorkEntry: (e: Omit<WorkEntry, 'id' | 'createdAt' | 'updatedAt'>) => WorkEntry;
updateWorkEntry: (id: string, patch: Partial<WorkEntry>) => void;
deleteWorkEntry: (id: string) => void;
// ─── CRUD: Payments ───────────────────────────────────────────────────────
addPayment: (p: Omit<Payment, 'id' | 'createdAt' | 'updatedAt'>) => Payment;
updatePayment: (id: string, patch: Partial<Payment>) => void;
deletePayment: (id: string) => void;
// ─── CRUD: Expenses ───────────────────────────────────────────────────────
addExpense: (e: Omit<Expense, 'id' | 'createdAt' | 'updatedAt'>) => Expense;
updateExpense: (id: string, patch: Partial<Expense>) => void;
deleteExpense: (id: string) => void;
// ─── Recurring expenses ───────────────────────────────────────────────────
addRecurringExpense: (e: Omit<RecurringExpense, 'id' | 'createdAt' | 'updatedAt'>) => RecurringExpense;
updateRecurringExpense: (id: string, patch: Partial<RecurringExpense>) => void;
deleteRecurringExpense: (id: string) => void;
/** Generate Expense entries for all un-logged recurring occurrences up to today */
applyRecurringExpenses: () => void;
// ─── Tax inputs ───────────────────────────────────────────────────────────
setTaxInputs: (year: number, inputs: Partial<TaxInputs>) => void;
addTaxPayment: (year: number, p: Omit<TaxPaymentRecord, 'id'>) => void;
deleteTaxPayment: (year: number, id: string) => void;
// ─── Dashboard ────────────────────────────────────────────────────────────
addChart: (c?: Partial<ChartConfig>) => void;
updateChart: (id: string, patch: Partial<ChartConfig>) => void;
removeChart: (id: string) => void;
setDashboardWidgets: (w: DashboardConfig['widgets']) => void;
addLedgerChart: (tab: 'work' | 'payments' | 'expenses' | 'tax', c?: Partial<ChartConfig>) => void;
updateLedgerChart: (tab: 'work' | 'payments' | 'expenses' | 'tax', id: string, patch: Partial<ChartConfig>) => void;
removeLedgerChart: (tab: 'work' | 'payments' | 'expenses' | 'tax', id: string) => void;
setPageTiles: (page: 'work' | 'payments' | 'expenses' | 'tax', tiles: LedgerTile[] | TaxTile[]) => void;
// ─── Settings ─────────────────────────────────────────────────────────────
setTheme: (theme: ThemeName, mode: ThemeMode) => void;
setDefaultRate: (rate: number) => void;
// ─── File import/export ───────────────────────────────────────────────────
/** Export a backup. Without a password the file is unencrypted plaintext. */
exportFile: (password?: string) => Promise<void>;
/** Import a backup. Password only required if the file is encrypted. */
importFile: (file: File, password?: string) => Promise<void>;
// ─── Persistence ──────────────────────────────────────────────────────────
persist: () => Promise<void>;
}
// ─── Implementation ──────────────────────────────────────────────────────────
export const useAppStore = create<AppStore>((set, get) => {
let persistTimer: ReturnType<typeof setTimeout> | null = null;
const schedulePersist = () => {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => get().persist(), 400);
};
const mutate = (fn: (d: AppData) => void) => {
set((s) => {
const next = structuredClone(s.data);
fn(next);
next.version += 1;
return { data: next };
});
schedulePersist();
};
return {
data: defaultData(),
vault: null,
ready: false,
saving: false,
lastSaveError: null,
// ─── Init ───────────────────────────────────────────────────────────────
init: async () => {
const vault = new Vault({ mode: 'cookie', username: '__anon__', password: getDeviceKey() });
let data: AppData;
try {
data = (await vault.load()) ?? defaultData();
} catch {
// Device key mismatch (localStorage cleared while cookies persisted) — start fresh
data = defaultData();
await vault.save(data);
}
// Migrate: add per-ledger chart arrays if missing
const dd = defaultDashboard();
if (!data.dashboard.workCharts) data.dashboard.workCharts = dd.workCharts;
if (!data.dashboard.paymentsCharts) data.dashboard.paymentsCharts = dd.paymentsCharts;
if (!data.dashboard.expensesCharts) data.dashboard.expensesCharts = dd.expensesCharts;
if (!data.dashboard.taxCharts) data.dashboard.taxCharts = dd.taxCharts;
// Add workValue to the main dashboard chart if it's missing
const mainChart = data.dashboard.charts[0];
if (mainChart && !mainChart.metrics.includes('workValue')) {
mainChart.metrics = ['workValue', ...mainChart.metrics];
}
// Migrate: add default rolling avg window to existing work charts that were never configured
for (const c of data.dashboard.workCharts) {
if (c.rollingAvgWindow === undefined) c.rollingAvgWindow = 10;
}
// Migrate: add recurringExpenses array if missing
if (!data.recurringExpenses) data.recurringExpenses = [];
// Migrate: add per-page tile configs if missing
if (!data.dashboard.workTiles) data.dashboard.workTiles = dd.workTiles;
if (!data.dashboard.paymentsTiles) data.dashboard.paymentsTiles = dd.paymentsTiles;
if (!data.dashboard.expensesTiles) data.dashboard.expensesTiles = dd.expensesTiles;
if (!data.dashboard.taxTiles) data.dashboard.taxTiles = dd.taxTiles;
// Migrate: expand widget set to include separated tiles if on old default
const hasNewWidgets = data.dashboard.widgets.some((w) =>
['ytdWorkValue', 'ytdPaymentsProj', 'ytdNetProj', 'ytdActualTax', 'taxRemainingDue'].includes(w),
);
if (!hasNewWidgets) data.dashboard.widgets = dd.widgets;
set({ vault, data, ready: true });
// Apply any pending recurring expense occurrences
setTimeout(() => get().applyRecurringExpenses(), 0);
},
// ─── Work CRUD ──────────────────────────────────────────────────────────
addWorkEntry: (e) => {
const now = Date.now();
const entry: WorkEntry = { ...e, id: uid(), createdAt: now, updatedAt: now };
mutate((d) => d.workEntries.push(entry));
return entry;
},
updateWorkEntry: (id, patch) => {
mutate((d) => {
const i = d.workEntries.findIndex((w) => w.id === id);
if (i >= 0) d.workEntries[i] = { ...d.workEntries[i], ...patch, updatedAt: Date.now() };
});
},
deleteWorkEntry: (id) => {
mutate((d) => { d.workEntries = d.workEntries.filter((w) => w.id !== id); });
},
// ─── Payment CRUD ───────────────────────────────────────────────────────
addPayment: (p) => {
const now = Date.now();
const payment: Payment = { ...p, id: uid(), createdAt: now, updatedAt: now };
mutate((d) => d.payments.push(payment));
return payment;
},
updatePayment: (id, patch) => {
mutate((d) => {
const i = d.payments.findIndex((p) => p.id === id);
if (i >= 0) d.payments[i] = { ...d.payments[i], ...patch, updatedAt: Date.now() };
});
},
deletePayment: (id) => {
mutate((d) => { d.payments = d.payments.filter((p) => p.id !== id); });
},
// ─── Expense CRUD ───────────────────────────────────────────────────────
addExpense: (e) => {
const now = Date.now();
const expense: Expense = { ...e, id: uid(), createdAt: now, updatedAt: now };
mutate((d) => d.expenses.push(expense));
return expense;
},
updateExpense: (id, patch) => {
mutate((d) => {
const i = d.expenses.findIndex((x) => x.id === id);
if (i >= 0) d.expenses[i] = { ...d.expenses[i], ...patch, updatedAt: Date.now() };
});
},
deleteExpense: (id) => {
mutate((d) => { d.expenses = d.expenses.filter((x) => x.id !== id); });
},
// ─── Recurring expenses ──────────────────────────────────────────────────
addRecurringExpense: (e) => {
const now = Date.now();
const rec: RecurringExpense = { ...e, id: uid(), createdAt: now, updatedAt: now };
mutate((d) => d.recurringExpenses.push(rec));
// Apply immediately after adding
setTimeout(() => get().applyRecurringExpenses(), 0);
return rec;
},
updateRecurringExpense: (id, patch) => {
mutate((d) => {
const i = d.recurringExpenses.findIndex((r) => r.id === id);
if (i >= 0) d.recurringExpenses[i] = { ...d.recurringExpenses[i], ...patch, updatedAt: Date.now() };
});
setTimeout(() => get().applyRecurringExpenses(), 0);
},
deleteRecurringExpense: (id) => {
mutate((d) => { d.recurringExpenses = d.recurringExpenses.filter((r) => r.id !== id); });
},
applyRecurringExpenses: () => {
const { data } = get();
const today = new Date().toISOString().slice(0, 10);
const toAdd: Expense[] = [];
for (const re of data.recurringExpenses) {
const occurrences = generateOccurrences(re, today);
const existing = new Set(
data.expenses.filter((e) => e.recurringSourceId === re.id).map((e) => e.date),
);
for (const date of occurrences) {
if (!existing.has(date)) {
const now = Date.now();
toAdd.push({
id: uid(), date,
amount: re.amount, description: re.description,
deductible: re.deductible, category: re.category,
recurringSourceId: re.id, createdAt: now, updatedAt: now,
});
}
}
}
if (toAdd.length > 0) {
mutate((d) => { d.expenses.push(...toAdd); });
}
},
// ─── Tax inputs ─────────────────────────────────────────────────────────
setTaxInputs: (year, inputs) => {
mutate((d) => {
const existing: Partial<TaxInputs> = d.taxInputs[year] ?? {};
d.taxInputs[year] = {
...existing,
...inputs,
taxYear: year,
filingStatus: inputs.filingStatus ?? existing.filingStatus ?? 'single',
};
});
},
addTaxPayment: (year, p) => {
const payment: TaxPaymentRecord = { ...p, id: uid() };
mutate((d) => {
const existing = d.taxInputs[year] ?? { taxYear: year, filingStatus: 'single' as const };
d.taxInputs[year] = { ...existing, taxPayments: [...(existing.taxPayments ?? []), payment] };
});
},
deleteTaxPayment: (year, id) => {
mutate((d) => {
const t = d.taxInputs[year];
if (t) t.taxPayments = (t.taxPayments ?? []).filter((p) => p.id !== id);
});
},
// ─── Dashboard ──────────────────────────────────────────────────────────
addChart: (c) => {
mutate((d) => {
d.dashboard.charts.push({
id: uid(),
type: 'line',
metrics: ['payments'],
granularity: 'day',
rangeStart: null,
rangeEnd: null,
yMin: null,
yMax: null,
...c,
});
});
},
updateChart: (id, patch) => {
mutate((d) => {
const i = d.dashboard.charts.findIndex((c) => c.id === id);
if (i >= 0) d.dashboard.charts[i] = { ...d.dashboard.charts[i], ...patch };
});
},
removeChart: (id) => {
mutate((d) => { d.dashboard.charts = d.dashboard.charts.filter((c) => c.id !== id); });
},
setDashboardWidgets: (w) => {
mutate((d) => { d.dashboard.widgets = w; });
},
addLedgerChart: (tab, c) => {
const key = `${tab}Charts` as 'workCharts' | 'paymentsCharts' | 'expensesCharts' | 'taxCharts';
const metric = tab === 'work' ? 'workValue' : tab === 'payments' ? 'payments' : 'expenses';
mutate((d) => {
d.dashboard[key].push({
id: uid(),
type: 'area',
metrics: [metric],
granularity: 'day',
rangeStart: null,
rangeEnd: null,
yMin: null,
yMax: null,
...c,
});
});
},
updateLedgerChart: (tab, id, patch) => {
const key = `${tab}Charts` as 'workCharts' | 'paymentsCharts' | 'expensesCharts' | 'taxCharts';
mutate((d) => {
const arr = d.dashboard[key];
const i = arr.findIndex((c) => c.id === id);
if (i >= 0) arr[i] = { ...arr[i], ...patch };
});
},
removeLedgerChart: (tab, id) => {
const key = `${tab}Charts` as 'workCharts' | 'paymentsCharts' | 'expensesCharts' | 'taxCharts';
mutate((d) => { d.dashboard[key] = d.dashboard[key].filter((c) => c.id !== id); });
},
setPageTiles: (page, tiles) => {
const key = `${page}Tiles` as 'workTiles' | 'paymentsTiles' | 'expensesTiles' | 'taxTiles';
mutate((d) => { (d.dashboard[key] as typeof tiles) = tiles; });
},
// ─── Settings ───────────────────────────────────────────────────────────
setTheme: (theme, mode) => {
mutate((d) => { d.settings.theme = theme; d.settings.mode = mode; });
},
setDefaultRate: (rate) => {
mutate((d) => { d.settings.defaultRate = rate; });
},
// ─── File import/export ─────────────────────────────────────────────────
exportFile: async (password) => {
const { vault, data } = get();
if (!vault) return;
await vault.exportToFile(data, password);
},
importFile: async (file, password) => {
const content = await file.text();
const data = await deserializeT99(content, password);
set({ data });
schedulePersist();
},
// ─── Persist ────────────────────────────────────────────────────────────
persist: async () => {
const { vault, data } = get();
if (!vault) return;
set({ saving: true, lastSaveError: null });
try {
await vault.save(data);
set({ saving: false });
} catch (err) {
set({ saving: false, lastSaveError: err instanceof Error ? err.message : String(err) });
}
},
};
});

241
src/store/timerStore.ts Normal file
View file

@ -0,0 +1,241 @@
/**
* Timer Store live work clock with crash recovery.
*
* State persists to a dedicated cookie on every tick (heartbeat).
* If the page loads and finds running=true with a stale heartbeat,
* we assume a crash/close and surface a recovery banner.
*/
import { create } from 'zustand';
import type { TimerState, TimerSplit, CrashRecovery } from '@/types';
import { uid } from '@/lib/id';
const COOKIE_KEY = 't99_timer';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 1 week
const HEARTBEAT_STALE_MS = 5000; // >5s since last heartbeat → crash
interface TimerStore extends TimerState {
/** Milliseconds on the live clock RIGHT NOW */
elapsedMs: number;
/** Crash-recovery banner payload (null = no crash detected) */
crashRecovery: CrashRecovery | null;
// Actions
start: () => void;
pause: () => void;
split: (label?: string) => TimerSplit;
reset: () => void;
setRate: (rate: number) => void;
updateSplit: (id: string, patch: Partial<TimerSplit>) => void;
deleteSplit: (id: string) => void;
markRecorded: (id: string, workEntryId: string) => void;
dismissCrashBanner: () => void;
/** Subtract the crash gap from the current clock */
subtractCrashGap: () => void;
// Internals
_tick: () => void;
_persistCookie: () => void;
_restoreFromCookie: () => void;
}
const initialTimer = (defaultRate: number): TimerState => ({
currentRate: defaultRate,
running: false,
runStartedAt: null,
accumulatedMs: 0,
splits: [],
lastHeartbeat: Date.now(),
});
export const useTimerStore = create<TimerStore>((set, get) => {
// Tick loop — updates elapsed display & writes heartbeat cookie
let tickHandle: ReturnType<typeof setInterval> | null = null;
const startTicking = () => {
if (tickHandle) return;
tickHandle = setInterval(() => get()._tick(), 1000);
};
const stopTicking = () => {
if (tickHandle) {
clearInterval(tickHandle);
tickHandle = null;
}
};
return {
...initialTimer(50),
elapsedMs: 0,
crashRecovery: null,
start: () => {
const s = get();
if (s.running) return;
set({
running: true,
runStartedAt: Date.now(),
});
startTicking();
get()._persistCookie();
},
pause: () => {
const s = get();
if (!s.running || s.runStartedAt == null) return;
const segment = Date.now() - s.runStartedAt;
set({
running: false,
runStartedAt: null,
accumulatedMs: s.accumulatedMs + segment,
elapsedMs: s.accumulatedMs + segment,
});
stopTicking();
get()._persistCookie();
},
split: (label) => {
const s = get();
// Compute elapsed up to this instant
const now = Date.now();
const live = s.running && s.runStartedAt != null ? now - s.runStartedAt : 0;
const splitMs = s.accumulatedMs + live;
const split: TimerSplit = {
id: uid(),
startedAt: s.runStartedAt ?? now - splitMs,
elapsedMs: splitMs,
rate: s.currentRate,
label,
recorded: false,
};
// Reset live clock but keep running
set({
splits: [...s.splits, split],
accumulatedMs: 0,
runStartedAt: s.running ? now : null,
elapsedMs: 0,
});
get()._persistCookie();
return split;
},
reset: () => {
const rate = get().currentRate;
stopTicking();
set({ ...initialTimer(rate), elapsedMs: 0, crashRecovery: null });
get()._persistCookie();
},
setRate: (rate) => {
// Only affects the LIVE clock; existing splits keep their own rate.
set({ currentRate: rate });
get()._persistCookie();
},
updateSplit: (id, patch) => {
set((s) => ({
splits: s.splits.map((sp) =>
sp.id === id ? { ...sp, ...patch } : sp,
),
}));
get()._persistCookie();
},
deleteSplit: (id) => {
set((s) => ({ splits: s.splits.filter((sp) => sp.id !== id) }));
get()._persistCookie();
},
markRecorded: (id, workEntryId) => {
set((s) => ({
splits: s.splits.map((sp) =>
sp.id === id
? { ...sp, recorded: true, recordedWorkEntryId: workEntryId }
: sp,
),
}));
get()._persistCookie();
},
dismissCrashBanner: () => {
set({ crashRecovery: null });
},
subtractCrashGap: () => {
const { crashRecovery, accumulatedMs } = get();
if (!crashRecovery) return;
set({
accumulatedMs: Math.max(0, accumulatedMs - crashRecovery.gapMs),
crashRecovery: null,
});
get()._tick(); // refresh elapsed display
get()._persistCookie();
},
// ─── Internals ──────────────────────────────────────────────────────────
_tick: () => {
const s = get();
const live = s.running && s.runStartedAt != null ? Date.now() - s.runStartedAt : 0;
set({ elapsedMs: s.accumulatedMs + live, lastHeartbeat: Date.now() });
get()._persistCookie();
},
_persistCookie: () => {
const s = get();
const snapshot: TimerState = {
currentRate: s.currentRate,
running: s.running,
runStartedAt: s.runStartedAt,
accumulatedMs: s.accumulatedMs,
splits: s.splits,
lastHeartbeat: s.lastHeartbeat,
};
try {
const json = JSON.stringify(snapshot);
document.cookie = `${COOKIE_KEY}=${encodeURIComponent(json)}; max-age=${COOKIE_MAX_AGE}; path=/; SameSite=Strict`;
} catch {
/* cookie too large — splits table probably huge; degrade silently */
}
},
_restoreFromCookie: () => {
const match = document.cookie.match(
new RegExp(`(?:^|; )${COOKIE_KEY}=([^;]*)`),
);
if (!match) return;
try {
const snap: TimerState = JSON.parse(decodeURIComponent(match[1]));
const now = Date.now();
// Detect crash: was running, heartbeat is stale
let crashRecovery: CrashRecovery | null = null;
if (snap.running && now - snap.lastHeartbeat > HEARTBEAT_STALE_MS) {
crashRecovery = {
crashTime: snap.lastHeartbeat,
reloadTime: now,
gapMs: now - snap.lastHeartbeat,
};
}
// Restore state. If it was running, we keep it running —
// runStartedAt is the ORIGINAL epoch, so elapsed naturally
// includes the gap (user can subtract it via the banner).
set({
...snap,
elapsedMs: snap.running && snap.runStartedAt != null
? snap.accumulatedMs + (now - snap.runStartedAt)
: snap.accumulatedMs,
crashRecovery,
});
if (snap.running) startTicking();
} catch {
/* corrupted cookie — ignore */
}
},
};
});
// Restore on module load
if (typeof document !== 'undefined') {
useTimerStore.getState()._restoreFromCookie();
}

View file

@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { useAppStore } from '@/store/appStore';
import type { ThemeName, ThemeMode } from '@/types';
export const THEME_NAMES: Array<{ id: ThemeName; label: string }> = [
{ id: 'standard', label: 'Standard' },
{ id: 'sakura', label: 'Sakura' },
{ id: 'pumpkin', label: 'Pumpkin' },
{ id: 'fall', label: 'Fall' },
{ id: 'aqua', label: 'Aqua' },
{ id: 'lavender', label: 'Lavender' },
{ id: 'comic', label: 'Comic' },
{ id: 'manga', label: 'Manga' },
{ id: 'highcontrast', label: 'High Contrast' },
{ id: 'cyberpunk', label: 'Cyberpunk' },
];
/** Applies the current theme to <html> via data attributes. */
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const theme = useAppStore((s) => s.data.settings.theme);
const mode = useAppStore((s) => s.data.settings.mode);
useEffect(() => {
applyTheme(theme, mode);
}, [theme, mode]);
return <>{children}</>;
}
export function applyTheme(theme: ThemeName, mode: ThemeMode) {
const html = document.documentElement;
html.setAttribute('data-theme', theme);
html.setAttribute('data-mode', mode);
}

894
src/themes/global.css Normal file
View file

@ -0,0 +1,894 @@
@import './themes.css';
/* ============================================================================
GLOBAL STYLES
========================================================================== */
* { box-sizing: border-box; }
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--fg);
font-size: 14px;
line-height: 1.5;
transition: background var(--transition), color var(--transition);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
margin: 0;
font-weight: 600;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ─── Layout primitives ───────────────────────────────────────────────────── */
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 56px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 16px;
}
.app-header .logo {
font-family: var(--font-display);
font-size: 18px;
font-weight: 700;
color: var(--accent);
}
.app-nav {
display: flex;
gap: 4px;
flex: 1;
}
.app-nav a {
padding: 8px 14px;
border-radius: var(--radius-sm);
color: var(--fg-muted);
font-weight: 500;
white-space: nowrap;
transition: background var(--transition), color var(--transition);
}
.app-nav a:hover {
background: var(--bg-elev-2);
color: var(--fg);
text-decoration: none;
}
.app-nav a.active {
background: var(--accent-muted);
color: var(--accent);
}
.app-body {
flex: 1;
overflow: auto;
padding: 24px;
}
/* ─── Resizable split layout ─────────────────────────────────────────────── */
.rsplit {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 0;
}
.rsplit-left {
flex: 0 0 var(--split-pct, 50%);
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.rsplit-right {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
/* Drag divider */
.rsplit-divider {
flex: 0 0 12px;
align-self: stretch;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
transition: background var(--transition);
border-radius: 4px;
}
.rsplit-divider:hover,
.rsplit--dragging .rsplit-divider { background: var(--accent-muted); }
.rsplit-divider-handle {
width: 4px;
height: 48px;
border-radius: 2px;
background: var(--border);
transition: background var(--transition);
}
.rsplit-divider:hover .rsplit-divider-handle,
.rsplit--dragging .rsplit-divider-handle { background: var(--accent); }
/* Prevent text selection while dragging */
.rsplit--dragging { user-select: none; }
/* Mobile toggle button — hidden on desktop */
.rsplit-toggle { display: none; }
/* ─── Mobile: vertical stack, charts collapsible above data ─────────────── */
@media (max-width: 900px) {
.rsplit { flex-direction: column; gap: 0; }
/* Toggle sits at the very top */
.rsplit-toggle {
display: flex;
align-items: center;
gap: 8px;
order: 1;
width: 100%;
padding: 10px 4px;
background: none;
border: none;
border-bottom: 1px solid var(--border);
color: var(--fg-muted);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
margin-bottom: 12px;
}
.rsplit-toggle:hover { color: var(--fg); background: var(--bg-elev-2); }
/* Charts — above data, hidden by default */
.rsplit-right {
order: 2;
flex: 0 0 auto;
width: 100%;
display: none;
margin-bottom: 16px;
}
.rsplit-right--open { display: flex; }
/* Divider hidden on mobile */
.rsplit-divider { display: none; }
/* Data — below charts */
.rsplit-left {
order: 3;
flex: 1 1 auto;
width: 100%;
}
}
/* ─── Cards ───────────────────────────────────────────────────────────────── */
.card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
box-shadow: var(--shadow);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-title { font-size: 15px; font-weight: 600; }
/* ─── Buttons ─────────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--fg);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
}
.btn:hover { background: var(--bg-elev-2); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
}
.btn-primary:hover { filter: brightness(1.1); }
.btn-danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.btn-ghost {
background: transparent;
border-color: transparent;
}
.btn-ghost:hover { background: var(--bg-elev-2); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-lg { padding: 12px 20px; font-size: 15px; }
.btn-icon { padding: 6px; width: 32px; height: 32px; }
.btn-group {
display: inline-flex;
gap: 0;
}
.btn-group .btn {
border-radius: 0;
margin-left: -1px;
}
.btn-group .btn:first-child { border-radius: var(--radius-sm) 0 0 var(--radius-sm); margin-left: 0; }
.btn-group .btn:last-child { border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }
.btn-group .btn.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); z-index: 1; }
/* ─── Payer autocomplete dropdown ────────────────────────────────────────── */
.payer-wrap { position: relative; }
.payer-dropdown {
position: absolute;
top: calc(100% + 2px);
left: 0;
right: 0;
z-index: 200;
background: var(--bg-elev);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
max-height: 180px;
overflow-y: auto;
}
.payer-option {
padding: 8px 10px;
font-size: 13px;
cursor: pointer;
color: var(--fg);
}
.payer-option:hover, .payer-option.active {
background: var(--accent-muted);
color: var(--accent);
}
/* ─── Forms ───────────────────────────────────────────────────────────────── */
.input, .select, .textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elev);
color: var(--fg);
font-family: inherit;
font-size: 13px;
transition: border-color var(--transition);
}
.input:focus, .select:focus, .textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-muted);
}
.input-inline {
padding: 4px 6px;
font-size: 12px;
}
.field { display: flex; flex-direction: column; gap: 4px; }
.field label { font-size: 12px; color: var(--fg-muted); font-weight: 500; }
.field-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; }
/* Highlighted prompt field for missing tax data */
.field-prompt {
position: relative;
border: 2px solid var(--warning);
border-radius: var(--radius);
padding: 12px;
background: color-mix(in srgb, var(--warning) 8%, transparent);
animation: pulse 2s infinite;
}
.field-prompt .prompt-reason {
font-size: 12px;
color: var(--fg-muted);
margin-top: 6px;
}
@keyframes pulse {
0%, 100% { border-color: var(--warning); }
50% { border-color: color-mix(in srgb, var(--warning) 50%, transparent); }
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 13px;
}
/* ─── Tables & spreadsheets ──────────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table th {
text-align: left;
padding: 10px 12px;
font-weight: 600;
color: var(--fg-muted);
border-bottom: 2px solid var(--border);
background: var(--bg-elev);
}
.data-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.data-table tbody tr:hover { background: var(--bg-elev-2); }
.data-table .num { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.data-table tfoot td { font-weight: 600; border-top: 2px solid var(--border); border-bottom: none; }
/* Hierarchical spreadsheet rows */
.hier-row { user-select: none; }
.hier-row.level-year { font-weight: 700; background: var(--bg-elev-2); }
.hier-row.level-month { font-weight: 600; }
.hier-row.level-day { font-weight: 500; color: var(--fg-muted); }
.hier-row.level-item { font-weight: 400; }
/* Clickable label — label-click focuses/collapses to that category */
.hier-label { cursor: pointer; }
.hier-label:hover { color: var(--accent); }
/* Item rows that open a detail popup on click */
.hier-row--clickable { cursor: pointer; }
.hier-row--clickable:hover { background: var(--accent-muted) !important; }
/* Day-row "+" add button — hidden until row is hovered */
.hier-add-btn { opacity: 0; transition: opacity var(--transition); }
.hier-row:hover .hier-add-btn { opacity: 1; }
.hier-toggle {
display: inline-flex;
width: 16px;
margin-right: 4px;
color: var(--fg-muted);
transition: transform var(--transition);
cursor: pointer;
}
.hier-toggle:empty { cursor: default; }
.hier-toggle.expanded { transform: rotate(90deg); }
/* Override table cell left padding — hier-cell handles it via flex layout */
.hier-row td:first-child { padding-left: 0; }
/* Flex container: [indent slots…] [toggle] [label] */
.hier-cell {
display: flex;
align-items: center;
padding-left: 8px;
}
/* Each indent slot is a fixed-width guide column */
.hier-indent {
flex: 0 0 20px;
align-self: stretch;
position: relative;
}
/* Full vertical guide line */
.hier-indent::before {
content: '';
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 1px;
background: var(--fg-muted);
opacity: 0.35;
}
/* Horizontal branch connector at midpoint */
.hier-indent.branch::after {
content: '';
position: absolute;
left: 8px;
top: 50%;
right: 0;
height: 1px;
background: var(--fg-muted);
opacity: 0.35;
}
/* ─── Stats widgets ───────────────────────────────────────────────────────── */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.stat-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
}
.stat-card .stat-label { font-size: 11px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card .stat-value { font-size: 24px; font-weight: 700; font-family: var(--font-mono); margin-top: 4px; }
.stat-card .stat-sub { font-size: 12px; color: var(--fg-muted); margin-top: 2px; }
.stat-card.positive .stat-value { color: var(--success); }
.stat-card.negative .stat-value { color: var(--danger); }
/* ─── Timer ───────────────────────────────────────────────────────────────── */
.timer-display {
font-family: var(--font-mono);
font-size: 56px;
font-weight: 700;
text-align: center;
letter-spacing: 2px;
padding: 24px 0;
}
.timer-earned {
font-family: var(--font-mono);
font-size: 28px;
font-weight: 600;
text-align: center;
color: var(--success);
}
.timer-controls {
display: flex;
justify-content: center;
gap: 12px;
margin: 16px 0;
}
.crash-banner {
background: color-mix(in srgb, var(--danger) 10%, transparent);
border: 2px solid var(--danger);
border-radius: var(--radius);
padding: 14px;
margin-bottom: 16px;
color: var(--danger);
font-size: 13px;
}
.crash-banner .crash-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
margin: 8px 0;
font-family: var(--font-mono);
}
.crash-banner .crash-actions { display: flex; gap: 8px; margin-top: 10px; }
.recorded-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background: var(--success);
color: #fff;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
/* ─── Modals ──────────────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow: auto;
padding: 20px;
}
.modal-title { font-size: 16px; margin-bottom: 16px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
/* ─── Tabs ────────────────────────────────────────────────────────────────── */
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
gap: 4px;
}
.tab {
padding: 10px 16px;
border: none;
background: none;
color: var(--fg-muted);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab:hover { color: var(--fg); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
/* ─── Inline spreadsheet ─────────────────────────────────────────────────── */
.ss-rangebar {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0 8px;
font-size: 13px;
flex-shrink: 0;
}
.ss-rangebar label { color: var(--fg-muted); font-size: 12px; }
.ss-rangebar-input { height: 32px; width: 160px; }
.ss-rangebar-summary { margin-left: auto; color: var(--fg-muted); font-size: 12px; }
.ss-scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.ss-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.ss-table th {
text-align: left;
padding: 7px 10px;
font-size: 11px;
font-weight: 600;
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: 0.4px;
background: var(--bg-elev);
border-bottom: 2px solid var(--border);
position: sticky;
top: 0;
z-index: 1;
white-space: nowrap;
}
.ss-table th.ss-right { text-align: right; }
.ss-table td {
border-bottom: 1px solid var(--border);
height: 34px;
padding: 0;
vertical-align: middle;
max-width: 260px;
}
/* Display cell — click to enter edit mode */
.ss-cell {
display: block;
width: 100%;
height: 100%;
padding: 7px 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
user-select: none;
box-sizing: border-box;
}
.ss-cell:hover { background: var(--accent-muted); }
.ss-cell.ss-right { text-align: right; }
.ss-empty { color: var(--fg-muted); }
/* Input that fills the cell when editing */
.ss-input {
display: block;
width: 100%;
height: 34px;
padding: 7px 10px;
border: none;
background: color-mix(in srgb, var(--accent) 12%, var(--bg-elev));
color: var(--fg);
font-family: inherit;
font-size: 13px;
outline: 2px solid var(--accent);
outline-offset: -2px;
box-sizing: border-box;
}
.ss-input.ss-right { text-align: right; }
select.ss-input { cursor: pointer; }
.ss-value { padding: 7px 10px; }
.ss-actions { padding: 0 4px; text-align: center; white-space: nowrap; }
/* Existing data rows */
.ss-row:hover { background: var(--bg-elev-2); }
.ss-row:hover .ss-cell:hover { background: var(--accent-muted); }
/* Add-new draft row */
.ss-draft td { background: color-mix(in srgb, var(--success) 6%, var(--bg-elev)); }
.ss-draft .ss-input {
background: color-mix(in srgb, var(--success) 10%, var(--bg-elev));
outline-color: var(--success);
}
/* Footer total row */
.ss-total td {
font-weight: 600;
font-size: 11px;
padding: 7px 10px;
border-top: 2px solid var(--border);
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.ss-total .ss-right { text-align: right; color: var(--fg); font-family: var(--font-mono); }
/* ─── Chart grid ──────────────────────────────────────────────────────────── */
/* minmax(min(400px,100%),1fr) prevents overflow when viewport < 400px */
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(400px, 100%), 1fr));
gap: 16px;
flex: 1;
min-height: 0;
}
/* ─── Footer ─────────────────────────────────────────────────────────────── */
.app-footer {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
padding: 10px 24px;
border-top: 1px solid var(--border);
background: var(--bg-elev);
color: var(--fg-muted);
font-size: 11px;
}
.app-footer-sep { opacity: 0.4; }
/* ─── Ko-fi header button & modal ────────────────────────────────────────── */
.kofi-header-btn {
flex-shrink: 0;
padding: 6px 14px;
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-fg);
font-size: 13px;
font-weight: 600;
border: none;
cursor: pointer;
transition: filter 0.15s ease;
}
.kofi-header-btn:hover { filter: brightness(1.12); }
.kofi-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.kofi-modal {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
width: 100%;
max-width: 500px;
/* tall enough for the 712px iframe + 48px header without scrolling */
height: min(760px, 95vh);
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
}
.kofi-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--accent);
color: var(--accent-fg);
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
}
.kofi-modal-close {
background: none;
border: none;
color: var(--accent-fg);
font-size: 16px;
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm);
line-height: 1;
opacity: 0.8;
transition: opacity 0.15s ease;
}
.kofi-modal-close:hover { opacity: 1; }
.kofi-modal iframe {
flex: 1;
min-height: 0;
display: block;
width: 100%;
background: #fff;
}
/* In dark mode, invert the iframe content then rotate hue back so colours
stay natural while the bright white background becomes dark.
Background is set to #fff so it inverts to near-black, matching the theme. */
[data-mode='dark'] .kofi-modal iframe {
filter: invert(1) hue-rotate(180deg);
}
/* ─── Hamburger / mobile nav ─────────────────────────────────────────────── */
.hamburger-btn {
display: none;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--fg);
font-size: 18px;
line-height: 1;
padding: 5px 10px;
cursor: pointer;
flex-shrink: 0;
position: relative;
z-index: 101;
}
.hamburger-btn:hover { background: var(--bg-elev-2); }
/* Overlay catches outside-clicks to close the menu */
.nav-overlay {
position: fixed;
inset: 0;
z-index: 99;
}
/* ─── Responsive ──────────────────────────────────────────────────────────── */
/* Tablets (≤900px) — switch to hamburger nav */
@media (max-width: 900px) {
.app-body { padding: 16px; }
.timer-display { font-size: 44px; }
.app-header { position: relative; }
.hamburger-btn { display: inline-flex; }
.header-status { margin-left: auto; }
.kofi-header-btn { display: none; }
.app-nav {
display: none;
flex-direction: column;
gap: 2px;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
padding: 8px;
z-index: 100;
}
.app-nav.open { display: flex; }
.app-nav a { padding: 10px 14px; border-radius: var(--radius-sm); }
}
/* Small phones (≤640px) — extra compact spacing */
@media (max-width: 640px) {
.app-body { padding: 10px; }
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.stat-card .stat-value { font-size: 20px; }
.timer-display { font-size: 36px; }
.timer-earned { font-size: 20px; }
.field-row { grid-template-columns: 1fr; }
.data-table { font-size: 12px; }
.data-table th, .data-table td { padding: 6px 8px; }
.modal { padding: 16px; }
.col-hide-sm { display: none; }
}
/* 4K TV (≥3840px) — only true 4K displays, no margin centering */
@media (min-width: 3840px) {
body { font-size: 16px; }
.app-header { height: 72px; padding: 0 40px; }
.app-body { padding: 40px; }
.stat-card .stat-value { font-size: 32px; }
.timer-display { font-size: 80px; }
}
/* ─── Utilities ───────────────────────────────────────────────────────────── */
.flex { display: flex; }
.flex-col { display: flex; flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: 4px; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.gap-4 { gap: 16px; }
.mt-2 { margin-top: 8px; }
.mt-4 { margin-top: 16px; }
.mb-2 { margin-bottom: 8px; }
.mb-4 { margin-bottom: 16px; }
.text-muted { color: var(--fg-muted); }
.text-success { color: var(--success); }
.text-danger { color: var(--danger); }
.text-sm { font-size: 12px; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.scroll-y { overflow-y: auto; }
.full-height { height: 100%; }

361
src/themes/themes.css Normal file
View file

@ -0,0 +1,361 @@
/* ============================================================================
THEME SYSTEM 10 themes × 2 modes = 20 palettes
Applied via: <html data-theme="sakura" data-mode="dark">
========================================================================== */
:root {
--font-body: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--font-display: var(--font-body);
--radius: 8px;
--radius-sm: 4px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
--transition: 150ms ease;
}
/* ─── STANDARD ──────────────────────────────────────────────────────────── */
[data-theme='standard'][data-mode='light'] {
--bg: #fafafa;
--bg-elev: #ffffff;
--bg-elev-2: #f3f4f6;
--fg: #111827;
--fg-muted: #6b7280;
--border: #e5e7eb;
--accent: #2563eb;
--accent-fg: #ffffff;
--accent-muted: #dbeafe;
--success: #059669;
--warning: #d97706;
--danger: #dc2626;
--chart-1: #2563eb; --chart-2: #059669; --chart-3: #d97706; --chart-4: #dc2626; --chart-5: #7c3aed;
}
[data-theme='standard'][data-mode='dark'] {
--bg: #0f1419;
--bg-elev: #1a1f26;
--bg-elev-2: #252b33;
--fg: #e5e7eb;
--fg-muted: #9ca3af;
--border: #2d333b;
--accent: #3b82f6;
--accent-fg: #ffffff;
--accent-muted: #1e3a5f;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--chart-1: #60a5fa; --chart-2: #34d399; --chart-3: #fbbf24; --chart-4: #f87171; --chart-5: #a78bfa;
}
/* ─── SAKURA (cherry blossom pink, soft) ────────────────────────────────── */
[data-theme='sakura'] { --font-display: 'Noto Serif JP', serif; }
[data-theme='sakura'][data-mode='light'] {
--bg: #fef7f9;
--bg-elev: #ffffff;
--bg-elev-2: #fce8ef;
--fg: #4a2b3a;
--fg-muted: #a87490;
--border: #f5d0de;
--accent: #e56b9f;
--accent-fg: #ffffff;
--accent-muted: #fadce8;
--success: #5fb878;
--warning: #d48806;
--danger: #d9506c;
--chart-1: #e56b9f; --chart-2: #5fb878; --chart-3: #f5a5c4; --chart-4: #d9506c; --chart-5: #ba8ab6;
}
[data-theme='sakura'][data-mode='dark'] {
--bg: #1f1519;
--bg-elev: #2b1e24;
--bg-elev-2: #3a2a32;
--fg: #f5e6eb;
--fg-muted: #bf9aa9;
--border: #4d3540;
--accent: #f08cb3;
--accent-fg: #1f1519;
--accent-muted: #4d2638;
--success: #6dd28f;
--warning: #e0a82e;
--danger: #f06a85;
--chart-1: #f08cb3; --chart-2: #6dd28f; --chart-3: #f5bdd2; --chart-4: #f06a85; --chart-5: #c9a3c6;
}
/* ─── PUMPKIN (autumn orange, cozy) ─────────────────────────────────────── */
[data-theme='pumpkin'][data-mode='light'] {
--bg: #fdf8f3;
--bg-elev: #ffffff;
--bg-elev-2: #faedd f;
--bg-elev-2: #faeddf;
--fg: #3d2817;
--fg-muted: #8a6b52;
--border: #ead6c2;
--accent: #d97706;
--accent-fg: #ffffff;
--accent-muted: #fde7c8;
--success: #5a8a3a;
--warning: #b45309;
--danger: #b91c1c;
--chart-1: #d97706; --chart-2: #5a8a3a; --chart-3: #f0b463; --chart-4: #b91c1c; --chart-5: #a16207;
}
[data-theme='pumpkin'][data-mode='dark'] {
--bg: #1a1410;
--bg-elev: #261e17;
--bg-elev-2: #352a1f;
--fg: #f5ede5;
--fg-muted: #bf9f82;
--border: #4d3d2d;
--accent: #f59e0b;
--accent-fg: #1a1410;
--accent-muted: #4d3310;
--success: #7ab555;
--warning: #e08b1f;
--danger: #e05b5b;
--chart-1: #f59e0b; --chart-2: #7ab555; --chart-3: #f5c97a; --chart-4: #e05b5b; --chart-5: #d97706;
}
/* ─── FALL (deep reds, browns, golds) ───────────────────────────────────── */
[data-theme='fall'][data-mode='light'] {
--bg: #faf6f0;
--bg-elev: #ffffff;
--bg-elev-2: #f2ebe0;
--fg: #2d1f14;
--fg-muted: #7d6550;
--border: #e0d3c2;
--accent: #8b3a2f;
--accent-fg: #ffffff;
--accent-muted: #f0d5d0;
--success: #4d7c3a;
--warning: #a15f0a;
--danger: #991b1b;
--chart-1: #8b3a2f; --chart-2: #b88a2b; --chart-3: #4d7c3a; --chart-4: #703e20; --chart-5: #c2532d;
}
[data-theme='fall'][data-mode='dark'] {
--bg: #191310;
--bg-elev: #241c17;
--bg-elev-2: #33261f;
--fg: #f0e8dd;
--fg-muted: #b39d85;
--border: #4d3b2d;
--accent: #c25b4d;
--accent-fg: #ffffff;
--accent-muted: #52251f;
--success: #6da052;
--warning: #d4951f;
--danger: #d94545;
--chart-1: #c25b4d; --chart-2: #d4a73d; --chart-3: #6da052; --chart-4: #9c6038; --chart-5: #e07856;
}
/* ─── AQUA (ocean teals & blues) ────────────────────────────────────────── */
[data-theme='aqua'][data-mode='light'] {
--bg: #f0fafb;
--bg-elev: #ffffff;
--bg-elev-2: #daf2f5;
--fg: #0f3640;
--fg-muted: #5a8590;
--border: #c2e5ea;
--accent: #0891b2;
--accent-fg: #ffffff;
--accent-muted: #cef4fa;
--success: #059669;
--warning: #c2850f;
--danger: #c23b3b;
--chart-1: #0891b2; --chart-2: #059669; --chart-3: #0ea5e9; --chart-4: #14b8a6; --chart-5: #3b82f6;
}
[data-theme='aqua'][data-mode='dark'] {
--bg: #0a1a1f;
--bg-elev: #10262e;
--bg-elev-2: #17353f;
--fg: #e0f4f7;
--fg-muted: #7fb0ba;
--border: #1f4550;
--accent: #22d3ee;
--accent-fg: #0a1a1f;
--accent-muted: #0f4050;
--success: #2dd4bf;
--warning: #e5b52e;
--danger: #f06060;
--chart-1: #22d3ee; --chart-2: #2dd4bf; --chart-3: #38bdf8; --chart-4: #5eead4; --chart-5: #60a5fa;
}
/* ─── LAVENDER (soft purples) ───────────────────────────────────────────── */
[data-theme='lavender'][data-mode='light'] {
--bg: #faf7fd;
--bg-elev: #ffffff;
--bg-elev-2: #f0eaf7;
--fg: #2d1f42;
--fg-muted: #7a659e;
--border: #e0d5ed;
--accent: #7c3aed;
--accent-fg: #ffffff;
--accent-muted: #ede5fa;
--success: #4d9e70;
--warning: #bf8f1a;
--danger: #c73e5d;
--chart-1: #7c3aed; --chart-2: #a78bfa; --chart-3: #4d9e70; --chart-4: #c73e5d; --chart-5: #9d6ad9;
}
[data-theme='lavender'][data-mode='dark'] {
--bg: #16121f;
--bg-elev: #211b2e;
--bg-elev-2: #2e2540;
--fg: #eee8f7;
--fg-muted: #aa95cc;
--border: #3e3055;
--accent: #a78bfa;
--accent-fg: #16121f;
--accent-muted: #332652;
--success: #6ec98f;
--warning: #e5b83d;
--danger: #e35b7a;
--chart-1: #a78bfa; --chart-2: #c4b5fd; --chart-3: #6ec98f; --chart-4: #e35b7a; --chart-5: #8b5cf6;
}
/* ─── COMIC (bold primary colors, playful) ──────────────────────────────── */
[data-theme='comic'] {
--font-display: 'Bangers', cursive;
--font-body: 'Comic Neue', cursive;
--radius: 12px;
}
[data-theme='comic'][data-mode='light'] {
--bg: #fffceb;
--bg-elev: #ffffff;
--bg-elev-2: #fff5c2;
--fg: #1a1a1a;
--fg-muted: #666666;
--border: #1a1a1a;
--accent: #ff3b3b;
--accent-fg: #ffffff;
--accent-muted: #ffd6d6;
--success: #00a651;
--warning: #ffa500;
--danger: #e60000;
--shadow: 4px 4px 0 #1a1a1a;
--shadow-lg: 6px 6px 0 #1a1a1a;
--chart-1: #ff3b3b; --chart-2: #00a651; --chart-3: #ffa500; --chart-4: #0066ff; --chart-5: #9933ff;
}
[data-theme='comic'][data-mode='dark'] {
--bg: #1a1a2e;
--bg-elev: #242445;
--bg-elev-2: #2d2d5a;
--fg: #f5f5dc;
--fg-muted: #b0b0cc;
--border: #f5f5dc;
--accent: #ff5c5c;
--accent-fg: #1a1a2e;
--accent-muted: #52262e;
--success: #00cc66;
--warning: #ffb833;
--danger: #ff3333;
--shadow: 4px 4px 0 #f5f5dc;
--shadow-lg: 6px 6px 0 #f5f5dc;
--chart-1: #ff5c5c; --chart-2: #00cc66; --chart-3: #ffb833; --chart-4: #5c85ff; --chart-5: #b36bff;
}
/* ─── MANGA (monochrome with screentone feel) ───────────────────────────── */
[data-theme='manga'] {
--font-display: 'Noto Serif JP', serif;
--radius: 2px;
}
[data-theme='manga'][data-mode='light'] {
--bg: #ffffff;
--bg-elev: #f7f7f7;
--bg-elev-2: #ebebeb;
--fg: #000000;
--fg-muted: #666666;
--border: #000000;
--accent: #000000;
--accent-fg: #ffffff;
--accent-muted: #d0d0d0;
--success: #2d5a2d;
--warning: #5a5a2d;
--danger: #5a2d2d;
--chart-1: #000000; --chart-2: #4d4d4d; --chart-3: #808080; --chart-4: #b3b3b3; --chart-5: #262626;
}
[data-theme='manga'][data-mode='dark'] {
--bg: #0a0a0a;
--bg-elev: #1a1a1a;
--bg-elev-2: #2a2a2a;
--fg: #ffffff;
--fg-muted: #aaaaaa;
--border: #ffffff;
--accent: #ffffff;
--accent-fg: #000000;
--accent-muted: #404040;
--success: #8ab88a;
--warning: #b8b88a;
--danger: #b88a8a;
--chart-1: #ffffff; --chart-2: #cccccc; --chart-3: #999999; --chart-4: #666666; --chart-5: #e6e6e6;
}
/* ─── HIGH CONTRAST (accessibility) ─────────────────────────────────────── */
[data-theme='highcontrast'] {
--radius: 2px;
--font-body: system-ui, sans-serif;
}
[data-theme='highcontrast'][data-mode='light'] {
--bg: #ffffff;
--bg-elev: #ffffff;
--bg-elev-2: #f0f0f0;
--fg: #000000;
--fg-muted: #333333;
--border: #000000;
--accent: #0000ee;
--accent-fg: #ffffff;
--accent-muted: #ccccff;
--success: #006600;
--warning: #8a5000;
--danger: #cc0000;
--chart-1: #0000ee; --chart-2: #006600; --chart-3: #8a5000; --chart-4: #cc0000; --chart-5: #660099;
}
[data-theme='highcontrast'][data-mode='dark'] {
--bg: #000000;
--bg-elev: #000000;
--bg-elev-2: #1a1a1a;
--fg: #ffffff;
--fg-muted: #dddddd;
--border: #ffffff;
--accent: #00ffff;
--accent-fg: #000000;
--accent-muted: #003333;
--success: #00ff00;
--warning: #ffff00;
--danger: #ff4d4d;
--chart-1: #00ffff; --chart-2: #00ff00; --chart-3: #ffff00; --chart-4: #ff4d4d; --chart-5: #ff00ff;
}
/* ─── CYBERPUNK (neon magenta + cyan on black) ──────────────────────────── */
[data-theme='cyberpunk'] {
--font-display: 'Orbitron', sans-serif;
--font-mono: 'Orbitron', monospace;
--radius: 0;
}
[data-theme='cyberpunk'][data-mode='light'] {
--bg: #f0ecff;
--bg-elev: #ffffff;
--bg-elev-2: #e5dcff;
--fg: #1a0f33;
--fg-muted: #6650a3;
--border: #b8a3ff;
--accent: #ff00a0;
--accent-fg: #ffffff;
--accent-muted: #ffccf0;
--success: #00b894;
--warning: #d9a000;
--danger: #e60052;
--chart-1: #ff00a0; --chart-2: #00d9ff; --chart-3: #ffdd00; --chart-4: #9d00ff; --chart-5: #00ffa6;
}
[data-theme='cyberpunk'][data-mode='dark'] {
--bg: #0a0014;
--bg-elev: #14051f;
--bg-elev-2: #1f0a33;
--fg: #f0e6ff;
--fg-muted: #9680cc;
--border: #3d1f66;
--accent: #ff00d4;
--accent-fg: #0a0014;
--accent-muted: #4d0040;
--success: #00ffbf;
--warning: #ffcc00;
--danger: #ff0055;
--shadow: 0 0 12px rgba(255, 0, 212, 0.3);
--shadow-lg: 0 0 24px rgba(255, 0, 212, 0.5);
--chart-1: #ff00d4; --chart-2: #00ffff; --chart-3: #ffcc00; --chart-4: #b300ff; --chart-5: #00ffbf;
}

406
src/types/index.ts Normal file
View file

@ -0,0 +1,406 @@
// ============================================================================
// Core Domain Types — ten99timecard
// ============================================================================
/** ISO-8601 date string (YYYY-MM-DD) */
export type ISODate = string;
/** Epoch milliseconds */
export type EpochMs = number;
// ─── Work Log ────────────────────────────────────────────────────────────────
/**
* A work-log entry. Users may enter EITHER a flat dollar amount OR
* hours × rate. If both are present, amount wins. A work log does NOT
* constitute taxable income that's what Payment is for.
*/
export interface WorkEntry {
id: string;
date: ISODate;
description: string;
/** Flat dollar amount (takes precedence if set alongside hours/rate) */
amount?: number;
/** Decimal hours (e.g. 1.5 = 90 min) */
hours?: number;
/** Rate per hour in USD */
rate?: number;
/** Optional client/project tag */
client?: string;
createdAt: EpochMs;
updatedAt: EpochMs;
}
/** Computed dollar value of a work entry */
export function workEntryValue(e: WorkEntry): number {
if (e.amount != null) return e.amount;
if (e.hours != null && e.rate != null) return e.hours * e.rate;
return 0;
}
// ─── Payments (taxable income) ───────────────────────────────────────────────
export interface Payment {
id: string;
date: ISODate;
amount: number;
/** Who paid you */
payer: string;
/** Optional: which work entries this payment covers */
workEntryIds?: string[];
/** 1099-NEC, 1099-K, 1099-MISC, direct, etc. */
form?: '1099-NEC' | '1099-K' | '1099-MISC' | 'direct' | 'other';
notes?: string;
createdAt: EpochMs;
updatedAt: EpochMs;
}
// ─── Expenses ────────────────────────────────────────────────────────────────
export interface Expense {
id: string;
date: ISODate;
amount: number;
description: string;
/** Whether this expense is tax-deductible (Schedule C) */
deductible: boolean;
category?: string;
/** Set when auto-generated from a RecurringExpense template */
recurringSourceId?: string;
createdAt: EpochMs;
updatedAt: EpochMs;
}
export type RecurringFrequency = 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'annually';
export interface RecurringExpense {
id: string;
description: string;
amount: number;
category?: string;
deductible: boolean;
frequency: RecurringFrequency;
/** Day of month (128) for monthly/quarterly/annual schedules */
dayOfMonth: number;
startDate: ISODate;
endDate?: ISODate | null;
createdAt: EpochMs;
updatedAt: EpochMs;
}
// ─── Work Timer ──────────────────────────────────────────────────────────────
/**
* A single split recorded from the live timer.
* Durations are stored in milliseconds for precision.
*/
export interface TimerSplit {
id: string;
/** When this split started (epoch ms) */
startedAt: EpochMs;
/** Total elapsed milliseconds for this split (excluding pauses) */
elapsedMs: number;
/** Rate per hour at the time this split was recorded */
rate: number;
/** Optional label */
label?: string;
/** Has this split been pushed to the work log? */
recorded: boolean;
/** If recorded, the WorkEntry id it created */
recordedWorkEntryId?: string;
}
/**
* Live timer state persisted to cookies for crash recovery.
* The running clock's elapsed time = (now - runStartedAt) + accumulatedMs
* when running; = accumulatedMs when paused.
*/
export interface TimerState {
/** Current rate per hour for the LIVE clock (splits keep their own rate) */
currentRate: number;
/** Is the clock currently running? */
running: boolean;
/** Epoch ms when the current run segment started (only meaningful if running) */
runStartedAt: EpochMs | null;
/** Milliseconds accumulated from prior run segments (i.e. before last pause) */
accumulatedMs: number;
/** Recorded splits */
splits: TimerSplit[];
/** Heartbeat — last time we wrote state to cookies. Used for crash detection. */
lastHeartbeat: EpochMs;
}
/**
* Crash-recovery banner payload. If the page loads and finds a TimerState
* with running=true and a stale heartbeat, we show this.
*/
export interface CrashRecovery {
/** When the page was last seen alive */
crashTime: EpochMs;
/** When the page was reloaded */
reloadTime: EpochMs;
/** reloadTime - crashTime */
gapMs: number;
}
// ─── Tax ─────────────────────────────────────────────────────────────────────
export type FilingStatus = 'single' | 'mfj' | 'mfs' | 'hoh';
/**
* User-provided tax-year inputs. These are the fields the Tax page will
* PROMPT FOR (highlighted) when they're missing but needed for a calculation.
*/
export interface TaxPaymentRecord {
id: string;
date: ISODate;
amount: number;
/** Which quarter this payment applies to (optional) */
quarter?: 1 | 2 | 3 | 4;
note?: string;
}
export interface TaxInputs {
taxYear: number;
filingStatus: FilingStatus;
/** Previous year's AGI — needed for safe-harbor calculation */
priorYearAGI?: number;
/** Previous year's total tax — needed for 100%/110% safe harbor */
priorYearTax?: number;
/** W-2 wages also earned this year (affects brackets) */
w2Wages?: number;
/** Federal withholding already taken (from W-2 or other) */
federalWithholding?: number;
/** Manual lump-sum override for estimated payments (legacy) */
estimatedPaymentsMade?: number;
/** Individually logged estimated tax payments */
taxPayments?: TaxPaymentRecord[];
/** State for state-tax estimate (optional; federal always computed) */
state?: string;
}
/** A single missing-field prompt produced by the tax engine */
export interface TaxPrompt {
field: keyof TaxInputs;
label: string;
reason: string;
/** Show as a highlighted input in the UI */
severity: 'required' | 'recommended';
}
/** Output of the tax calculation engine */
export interface TaxResult {
taxYear: number;
/** Adjusted gross income */
agi: number;
// Schedule C equivalents
grossReceipts: number; // sum of payments
deductibleExpenses: number; // sum of deductible expenses
netProfit: number; // grossReceipts - deductibleExpenses
// Self-employment tax
seTaxableBase: number; // netProfit * 0.9235
socialSecurityTax: number;
medicareTax: number;
additionalMedicareTax: number;
totalSETax: number;
// Income tax
seTaxDeduction: number; // 50% of SE tax (above-the-line)
qbiDeduction: number; // Section 199A, up to 20% of QBI
standardDeduction: number;
taxableIncome: number;
federalIncomeTax: number;
// Bottom line
totalFederalTax: number; // income tax + SE tax
alreadyPaid: number; // withholding + estimated payments made
remainingDue: number;
// Quarterly estimates
quarterlySchedule: QuarterlyPayment[];
safeHarborAmount: number | null; // null if we lack prior-year data
safeHarborMet: boolean | null;
// Missing-data prompts for the UI
prompts: TaxPrompt[];
// Warnings/notes for display
notes: string[];
}
export interface QuarterlyPayment {
quarter: 1 | 2 | 3 | 4;
dueDate: ISODate;
/** Amount recommended for this quarter (even split of annual estimate) */
projectedAmount: number;
/** Adjusted amount if remaining estimate distributed across unpaid quarters */
remainingAmount: number;
/** Safe-harbor minimum (if prior-year data available) */
safeHarborAmount: number | null;
/** Has this due date passed? */
isPastDue: boolean;
}
// ─── Charts & Dashboard ──────────────────────────────────────────────────────
export type ChartType = 'line' | 'bar' | 'area' | 'pie';
export type ChartMetric =
| 'workValue'
| 'payments'
| 'expenses'
| 'netIncome'
| 'cumulativePayments'
| 'cumulativeNet';
export type ChartGranularity = 'day' | 'week' | 'month' | 'year';
export interface ChartConfig {
id: string;
type: ChartType;
metrics: ChartMetric[];
granularity: ChartGranularity;
/** ISO date range; null = auto (current year) */
rangeStart: ISODate | null;
rangeEnd: ISODate | null;
/** Y-axis override; null = auto */
yMin: number | null;
yMax: number | null;
title?: string;
/** Rolling average window in periods; null/undefined = disabled */
rollingAvgWindow?: number | null;
}
export interface DashboardConfig {
charts: ChartConfig[];
/** Which stat widgets to show at top of dashboard */
widgets: DashboardWidget[];
/** Per-ledger-page chart configs */
workCharts: ChartConfig[];
paymentsCharts: ChartConfig[];
expensesCharts: ChartConfig[];
/** Tax page charts — empty by default */
taxCharts: ChartConfig[];
/** Which stat tiles to show on each ledger page */
workTiles: LedgerTile[];
paymentsTiles: LedgerTile[];
expensesTiles: LedgerTile[];
/** Which tax result fields to show as tiles on the tax page */
taxTiles: TaxTile[];
}
export type DashboardWidget =
| 'ytdWorkValue'
| 'ytdWorkProj'
| 'ytdPayments'
| 'ytdPaymentsProj'
| 'ytdExpenses'
| 'ytdNet'
| 'ytdNetProj'
| 'nextQuarterlyDue'
| 'projectedAnnualTax'
| 'ytdActualTax'
| 'taxRemainingDue'
| 'avgMonthlyNet'
| 'avgDailyWork';
export type LedgerTile =
| 'ytd'
| 'avgMonth'
| 'yearProj'
| 'thisMonth'
| 'avgDay'
| 'monthProj'
| 'today';
export type TaxTile =
| 'grossReceipts'
| 'deductibleExpenses'
| 'netProfit'
| 'seTaxableBase'
| 'socialSecurityTax'
| 'medicareTax'
| 'additionalMedicareTax'
| 'totalSETax'
| 'seTaxDeduction'
| 'qbiDeduction'
| 'standardDeduction'
| 'agi'
| 'taxableIncome'
| 'federalIncomeTax'
| 'totalFederalTax'
| 'alreadyPaid'
| 'remainingDue';
// ─── Themes ──────────────────────────────────────────────────────────────────
export type ThemeName =
| 'standard'
| 'sakura'
| 'pumpkin'
| 'fall'
| 'aqua'
| 'lavender'
| 'comic'
| 'manga'
| 'highcontrast'
| 'cyberpunk';
export type ThemeMode = 'light' | 'dark';
// ─── Settings & App State ────────────────────────────────────────────────────
export interface Settings {
theme: ThemeName;
mode: ThemeMode;
/** Default hourly rate, pre-fills timer & new work entries */
defaultRate: number;
}
/**
* The entire persisted app state. This whole object is encrypted
* before hitting any storage backend (cookies, file, or Mongo blob).
*/
export interface AppData {
workEntries: WorkEntry[];
payments: Payment[];
expenses: Expense[];
recurringExpenses: RecurringExpense[];
taxInputs: Record<number, TaxInputs>; // keyed by tax year
dashboard: DashboardConfig;
settings: Settings;
/** Monotonically increasing for optimistic cloud sync */
version: number;
}
// ─── Auth ────────────────────────────────────────────────────────────────────
// ─── Stats ───────────────────────────────────────────────────────────────────
export interface PeriodStats {
/** Period label, e.g. "2025", "2025-03", "2025-03-15" */
label: string;
workValue: number;
payments: number;
expenses: number;
deductibleExpenses: number;
net: number;
/** Average per child period (e.g. year→avg per month) */
avgPerChild: number | null;
/** Projection to end of current period based on run rate */
projected: number | null;
/** Number of child periods (for context on averages) */
childCount: number;
}
/** Hierarchical tree node for the expandable spreadsheet */
export interface HierNode {
key: string;
level: 'year' | 'month' | 'day' | 'item';
label: string;
value: number;
children: HierNode[];
/** Only populated on 'item' leaves */
entry?: WorkEntry | Payment | Expense;
}

24
tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

24
vite.config.ts Normal file
View file

@ -0,0 +1,24 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
server: {
port: 5173,
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/__tests__/setup.ts',
css: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['**/*.d.ts', '**/__tests__/**', '**/main.tsx'],
},
},
});