initial code commit

This commit is contained in:
Deven Thiel 2026-03-04 21:21:59 -05:00
commit 27bb45f7df
56 changed files with 15106 additions and 0 deletions

15
client/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>

36
client/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "ten99timecard-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "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"
}
}

58
client/src/App.tsx Normal file
View file

@ -0,0 +1,58 @@
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom';
import { useAppStore } from '@/store/appStore';
import { ThemeProvider } from '@/themes/ThemeProvider';
import { LoginScreen } from '@/components/auth/LoginScreen';
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 unlocked = useAppStore((s) => s.localAuth.unlocked);
const username = useAppStore((s) => s.localAuth.username);
const saving = useAppStore((s) => s.saving);
const saveError = useAppStore((s) => s.lastSaveError);
return (
<ThemeProvider>
{!unlocked ? (
<LoginScreen />
) : (
<BrowserRouter>
<div className="app-shell">
<header className="app-header">
<span className="logo">ten99timecard</span>
<nav className="app-nav">
<NavLink to="/" end>Dashboard</NavLink>
<NavLink to="/work">Work</NavLink>
<NavLink to="/payments">Payments</NavLink>
<NavLink to="/expenses">Expenses</NavLink>
<NavLink to="/tax">Tax</NavLink>
<NavLink to="/timer">Timer</NavLink>
<NavLink to="/settings">Settings</NavLink>
</nav>
<div className="flex items-center gap-2 text-sm">
{saving && <span className="text-muted">Saving</span>}
{saveError && <span className="text-danger" title={saveError}> Save failed</span>}
<span className="text-muted">{username}</span>
</div>
</header>
<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>
</div>
</BrowserRouter>
)}
</ThemeProvider>
);
}

View file

@ -0,0 +1,151 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useAppStore } from '@/store/appStore';
describe('appStore — CRUD', () => {
beforeEach(async () => {
// Reset to logged-in fresh state
useAppStore.setState({
data: {
workEntries: [],
payments: [],
expenses: [],
taxInputs: {},
dashboard: { charts: [], widgets: [] },
settings: { theme: 'standard', mode: 'dark', storageMode: 'cookie', defaultRate: 50 },
version: 1,
},
localAuth: { unlocked: true, username: 'test' },
cloudAuth: { token: null, email: null, provider: null },
vault: null, // no persistence in tests
});
});
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); // unchanged
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' });
const v1 = useAppStore.getState().data.version;
expect(v1).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);
});
});
describe('appStore — auth', () => {
beforeEach(() => {
useAppStore.getState().logout();
});
it('register creates encrypted vault and unlocks', async () => {
await useAppStore.getState().register('alice', 'my-strong-password');
expect(useAppStore.getState().localAuth.unlocked).toBe(true);
expect(useAppStore.getState().localAuth.username).toBe('alice');
expect(document.cookie).toContain('t99_alice');
});
it('register fails if user exists', async () => {
await useAppStore.getState().register('bob', 'password123');
useAppStore.getState().logout();
await expect(
useAppStore.getState().register('bob', 'different')
).rejects.toThrow();
});
it('login succeeds with correct password', async () => {
await useAppStore.getState().register('carol', 'secret-pass-123');
useAppStore.getState().addWorkEntry({ date: '2024-01-01', description: 'marker', amount: 999 });
await useAppStore.getState().persist();
useAppStore.getState().logout();
await useAppStore.getState().login('carol', 'secret-pass-123');
expect(useAppStore.getState().localAuth.unlocked).toBe(true);
expect(useAppStore.getState().data.workEntries[0].description).toBe('marker');
});
it('login fails with wrong password', async () => {
await useAppStore.getState().register('dave', 'correct-pass');
useAppStore.getState().logout();
await expect(
useAppStore.getState().login('dave', 'wrong-pass')
).rejects.toThrow(/Wrong password/);
});
it('logout locks and clears data', async () => {
await useAppStore.getState().register('eve', 'password123');
useAppStore.getState().addWorkEntry({ date: '2024-01-01', description: 'secret' });
useAppStore.getState().logout();
expect(useAppStore.getState().localAuth.unlocked).toBe(false);
expect(useAppStore.getState().data.workEntries).toHaveLength(0);
});
});

View file

@ -0,0 +1,287 @@
import { describe, it, expect, beforeEach, 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 { LoginScreen } from '@/components/auth/LoginScreen';
import { useAppStore } from '@/store/appStore';
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();
// $1,500.00 appears in both year row and grand total
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', () => {
// Helper: inputs are wrapped in .field divs with sibling <label>s;
// query by type+required for number inputs
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();
});
});
// ─── LoginScreen ─────────────────────────────────────────────────────────────
describe('LoginScreen', () => {
beforeEach(() => {
useAppStore.getState().logout();
});
const fillRegisterForm = async (container: HTMLElement, user: string, pw: string, conf: string) => {
await userEvent.click(screen.getByRole('button', { name: 'Create Vault' }));
const inputs = container.querySelectorAll('form input');
await userEvent.type(inputs[0] as HTMLInputElement, user);
await userEvent.type(inputs[1] as HTMLInputElement, pw);
await userEvent.type(inputs[2] as HTMLInputElement, conf);
};
it('shows login form by default', () => {
render(<LoginScreen />);
// "Unlock" appears as both tab button and submit; check by presence of username input
expect(screen.getByText('Username')).toBeInTheDocument();
expect(screen.queryByText(/Confirm password/)).not.toBeInTheDocument();
});
it('switches to register mode', async () => {
render(<LoginScreen />);
await userEvent.click(screen.getByRole('button', { name: 'Create Vault' }));
expect(screen.getByText(/Confirm password/)).toBeInTheDocument();
});
it('rejects mismatched passwords on register', async () => {
const { container } = render(<LoginScreen />);
await fillRegisterForm(container, 'user', 'password1', 'password2');
await userEvent.click(screen.getByRole('button', { name: 'Create & Unlock' }));
expect(screen.getByText(/do not match/)).toBeInTheDocument();
});
it('rejects short passwords', async () => {
const { container } = render(<LoginScreen />);
await fillRegisterForm(container, 'user', 'short', 'short');
await userEvent.click(screen.getByRole('button', { name: 'Create & Unlock' }));
expect(screen.getByText(/at least 8 characters/)).toBeInTheDocument();
});
});

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);
});
});

View file

@ -0,0 +1,37 @@
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);
};
// Reset cookies 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();
});

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');
});
});

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,171 @@
/**
* Login / Register screen gates the whole app.
* Local auth (password encryption key) + optional cloud auth.
*/
import { useState } from 'react';
import { useAppStore } from '@/store/appStore';
export function LoginScreen() {
const login = useAppStore((s) => s.login);
const register = useAppStore((s) => s.register);
const setCloudAuth = useAppStore((s) => s.setCloudAuth);
const [mode, setMode] = useState<'login' | 'register'>('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Cloud auth sub-panel
const [showCloud, setShowCloud] = useState(false);
const [cloudUrl, setCloudUrl] = useState('');
const [cloudEmail, setCloudEmail] = useState('');
const [cloudPass, setCloudPass] = useState('');
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (mode === 'register' && password !== confirm) {
setError('Passwords do not match');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setBusy(true);
try {
if (mode === 'register') await register(username, password);
else await login(username, password);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const cloudEmailLogin = async (isSignup: boolean) => {
if (!cloudUrl || !cloudEmail || !cloudPass) return;
setBusy(true);
setError(null);
try {
const res = await fetch(`${cloudUrl}/api/auth/${isSignup ? 'signup' : 'login'}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: cloudEmail, password: cloudPass }),
});
if (!res.ok) throw new Error((await res.json()).error ?? 'Cloud auth failed');
const { token } = await res.json();
setCloudAuth(token, cloudEmail, 'email');
alert('Cloud authenticated. Now enter a local encryption password above.');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const cloudGoogleLogin = () => {
if (!cloudUrl) {
setError('Enter cloud API URL first');
return;
}
// Open OAuth popup — server redirects back with token in query
const w = window.open(`${cloudUrl}/api/auth/google`, 'oauth', 'width=500,height=600');
const handler = (e: MessageEvent) => {
if (e.data?.type === 't99-oauth' && e.data.token) {
setCloudAuth(e.data.token, e.data.email, 'google');
window.removeEventListener('message', handler);
w?.close();
}
};
window.addEventListener('message', handler);
};
return (
<div className="flex items-center" style={{ justifyContent: 'center', minHeight: '100vh', padding: 20 }}>
<div className="card" style={{ width: '100%', maxWidth: 420 }}>
<h1 style={{ fontSize: 24, marginBottom: 4, fontFamily: 'var(--font-display)', color: 'var(--accent)' }}>
ten99timecard
</h1>
<p className="text-muted text-sm mb-4">
Income tracking & quarterly tax planning for 1099 workers
</p>
<div className="btn-group mb-4" style={{ width: '100%' }}>
<button className={`btn ${mode === 'login' ? 'active' : ''}`} style={{ flex: 1 }} onClick={() => setMode('login')}>
Unlock
</button>
<button className={`btn ${mode === 'register' ? 'active' : ''}`} style={{ flex: 1 }} onClick={() => setMode('register')}>
Create Vault
</button>
</div>
<form onSubmit={submit} className="flex-col gap-3">
<div className="field">
<label>Username</label>
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} required autoComplete="username" />
</div>
<div className="field">
<label>
{mode === 'register' ? 'Encryption password' : 'Password'}
</label>
<input type="password" className="input" value={password} onChange={(e) => setPassword(e.target.value)} required autoComplete={mode === 'register' ? 'new-password' : 'current-password'} />
</div>
{mode === 'register' && (
<>
<div className="field">
<label>Confirm password</label>
<input type="password" className="input" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
</div>
<p className="text-sm text-muted">
This password encrypts your data. <strong>It cannot be recovered.</strong>
</p>
</>
)}
{error && <div className="text-danger text-sm">{error}</div>}
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Working…' : mode === 'register' ? 'Create & Unlock' : 'Unlock'}
</button>
</form>
{/* Cloud auth collapsible */}
<div className="mt-4">
<button className="btn btn-sm btn-ghost" onClick={() => setShowCloud(!showCloud)}>
{showCloud ? '▾' : '▸'} Optional: connect to cloud storage
</button>
{showCloud && (
<div className="flex-col gap-2 mt-2" style={{ padding: 12, background: 'var(--bg-elev-2)', borderRadius: 6 }}>
<div className="field">
<label>Server URL</label>
<input className="input" value={cloudUrl} onChange={(e) => setCloudUrl(e.target.value)} placeholder="https://your-server.example.com" />
</div>
<div className="field-row">
<div className="field">
<label>Email</label>
<input type="email" className="input" value={cloudEmail} onChange={(e) => setCloudEmail(e.target.value)} />
</div>
<div className="field">
<label>Cloud password</label>
<input type="password" className="input" value={cloudPass} onChange={(e) => setCloudPass(e.target.value)} />
</div>
</div>
<div className="flex gap-2">
<button className="btn btn-sm" onClick={() => cloudEmailLogin(false)} disabled={busy}>Sign in</button>
<button className="btn btn-sm" onClick={() => cloudEmailLogin(true)} disabled={busy}>Sign up</button>
<button className="btn btn-sm" onClick={cloudGoogleLogin} disabled={busy}>Sign in with Google</button>
</div>
<p className="text-sm text-muted">
Cloud auth identifies WHICH blob to load. Your encryption password above is still what protects the data.
</p>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,300 @@
/**
* 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,
LineChart, Line,
BarChart, Bar,
AreaChart, 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;
}
export function ChartPanel({ config, onChange, onRemove }: 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 data = useMemo(
() =>
buildChartSeries(
work,
payments,
expenses,
config.metrics,
config.granularity,
config.rangeStart,
config.rangeEnd,
),
[work, payments, expenses, config],
);
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>
</div>
)}
{/* Chart render */}
<div style={{ flex: 1, minHeight: 200 }}>
{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="100%">
{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}
/>
))}
</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 (
<AreaChart {...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}
/>
))}
</AreaChart>
);
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,30 @@
/**
* Right-side chart column used on data pages.
* Shows the user's configured charts + an "Add chart" button.
*/
import { useAppStore } from '@/store/appStore';
import { ChartPanel } from './ChartPanel';
export function ChartSidebar() {
const charts = useAppStore((s) => s.data.dashboard.charts);
const addChart = useAppStore((s) => s.addChart);
const updateChart = useAppStore((s) => s.updateChart);
const removeChart = useAppStore((s) => s.removeChart);
return (
<>
{charts.map((c) => (
<ChartPanel
key={c.id}
config={c}
onChange={(patch) => updateChart(c.id, patch)}
onRemove={charts.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,234 @@
/**
* Reusable add/edit entry form. Supports flat-amount OR hours×rate input.
*/
import { useState } from 'react';
import type { WorkEntry, Payment, Expense } from '@/types';
import { todayISO } from '@/lib/format';
// ─── Work Entry ──────────────────────────────────────────────────────────────
type WorkDraft = Omit<WorkEntry, 'id' | 'createdAt' | 'updatedAt'>;
export function WorkEntryForm({
initial,
defaultRate,
onSubmit,
onCancel,
}: {
initial?: Partial<WorkDraft>;
defaultRate: number;
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>
<input className="input" value={client} onChange={(e) => setClient(e.target.value)} placeholder="optional" />
</div>
</div>
<div className="field">
<label>Description</label>
<input className="input" value={desc} onChange={(e) => setDesc(e.target.value)} placeholder="What did you work on?" required />
</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,
onSubmit,
onCancel,
}: {
initial?: Partial<PaymentDraft>;
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 ?? '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>
<input className="input" value={payer} onChange={(e) => setPayer(e.target.value)} required />
</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,179 @@
/**
* Hierarchical expandable spreadsheet.
* Year Month Day Item, with per-row toggles AND global
* "expand to level" buttons.
*/
import { useState, useMemo, useCallback } 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's edit/delete */
onEdit?: (node: HierNode) => void;
onDelete?: (node: HierNode) => 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,
};
export function HierSpreadsheet({ nodes, onEdit, onDelete, valueLabel }: Props) {
// Expanded keys set. We use a Set so individual rows can be toggled
// independently of the global expand-level buttons.
const [expanded, setExpanded] = useState<Set<string>>(new Set());
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;
});
}, []);
/** Expand all nodes up to and including the given level */
const expandToLevel = useCallback(
(level: ExpandLevel) => {
const depth = LEVEL_ORDER[level];
const keys = new Set<string>();
const walk = (ns: HierNode[], d: number) => {
for (const n of ns) {
if (d < depth) keys.add(n.key);
walk(n.children, d + 1);
}
};
walk(nodes, 0);
setExpanded(keys);
},
[nodes],
);
const collapseAll = useCallback(() => setExpanded(new Set()), []);
// 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(nodes, 0);
return out;
}, [nodes, expanded]);
const grandTotal = useMemo(
() => nodes.reduce((s, n) => s + n.value, 0),
[nodes],
);
return (
<div className="flex-col gap-2 full-height">
{/* Expand level controls */}
<div className="flex items-center justify-between">
<div className="btn-group">
<button className="btn btn-sm" onClick={collapseAll} title="Collapse all">
Year
</button>
<button className="btn btn-sm" onClick={() => expandToLevel('month')}>
Month
</button>
<button className="btn btn-sm" onClick={() => expandToLevel('day')}>
Day
</button>
<button className="btn btn-sm" onClick={() => expandToLevel('item')}>
Item
</button>
</div>
<span className="text-muted text-sm">{rows.length} rows</span>
</div>
{/* Table */}
<div className="scroll-y" style={{ flex: 1, minHeight: 0 }}>
<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}
className={clsx('hier-row', `level-${node.level}`)}
onClick={hasChildren ? () => toggle(node.key) : undefined}
>
<td className={`indent-${depth}`}>
{hasChildren && (
<span className={clsx('hier-toggle', isExpanded && 'expanded')}>
</span>
)}
{!hasChildren && <span className="hier-toggle" />}
{node.label}
</td>
<td className="num">{fmtMoney(node.value)}</td>
<td className="num">
{isItem && (
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
{onEdit && (
<button
className="btn btn-sm btn-ghost"
onClick={() => onEdit(node)}
title="Edit"
>
</button>
)}
{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,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
client/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
client/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}`;
}

View file

@ -0,0 +1,289 @@
/**
* 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;
}
// ─── 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;
}

View file

@ -0,0 +1,201 @@
/**
* Storage Adapters
*
* Three backends, one interface. All backends store an ENCRYPTED blob;
* encryption happens in the caller (store layer) before hitting any adapter.
*
* - CookieStorage: for small datasets & session continuity (chunked)
* - FileStorage: save/load via browser download + file picker
* - CloudStorage: POST/GET encrypted blob to the ten99timecard-server API
*/
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 */
}
}
// ─── Cloud ───────────────────────────────────────────────────────────────────
/**
* Talks to ten99timecard-server. The server NEVER sees plaintext we
* send it the same encrypted blob we'd put in cookies. The server just
* needs a JWT to identify WHICH blob to store.
*/
export class CloudStorage implements StorageAdapter {
constructor(
private apiUrl: string,
private getToken: () => string | null,
) {}
async save(blob: string): Promise<void> {
const token = this.getToken();
if (!token) throw new Error('Not authenticated with cloud');
const res = await fetch(`${this.apiUrl}/api/data`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ blob }),
});
if (!res.ok) throw new Error(`Cloud save failed: ${res.status}`);
}
async load(): Promise<string | null> {
const token = this.getToken();
if (!token) throw new Error('Not authenticated with cloud');
const res = await fetch(`${this.apiUrl}/api/data`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Cloud load failed: ${res.status}`);
const json = await res.json();
return json.blob ?? null;
}
async clear(): Promise<void> {
const token = this.getToken();
if (!token) return;
await fetch(`${this.apiUrl}/api/data`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
}
}
// ─── 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;
}

View file

@ -0,0 +1,79 @@
/**
* Vault the bridge between app state, encryption, and storage adapters.
*/
import type { AppData, StorageMode } from '@/types';
import { encrypt, decrypt } from '@/lib/crypto/encryption';
import {
CookieStorage,
FileStorage,
CloudStorage,
type StorageAdapter,
} from './adapters';
export interface VaultConfig {
mode: StorageMode;
username: string;
password: string;
/** Required iff mode === 'cloud' */
apiUrl?: string;
getCloudToken?: () => string | null;
}
export class Vault {
private adapter: StorageAdapter;
private password: string;
/** File adapter is held separately so the UI can call setFile() */
public readonly fileAdapter: FileStorage;
constructor(cfg: VaultConfig) {
this.password = cfg.password;
this.fileAdapter = new FileStorage();
switch (cfg.mode) {
case 'file':
this.adapter = this.fileAdapter;
break;
case 'cloud':
if (!cfg.apiUrl || !cfg.getCloudToken) {
throw new Error('Cloud storage requires apiUrl and getCloudToken');
}
this.adapter = new CloudStorage(cfg.apiUrl, cfg.getCloudToken);
break;
case 'cookie':
default:
this.adapter = new CookieStorage(cfg.username);
break;
}
}
async save(data: AppData): Promise<void> {
const json = JSON.stringify(data);
const blob = await encrypt(json, 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 an encrypted file regardless of active storage mode */
async exportToFile(data: AppData): Promise<void> {
const json = JSON.stringify(data);
const blob = await encrypt(json, this.password);
await this.fileAdapter.save(blob);
}
}
/** 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);
const blob = await cs.load();
return blob != null;
}

View file

@ -0,0 +1,206 @@
/**
* 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,
};
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
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];
}
export function availableTaxYears(): number[] {
return Object.keys(YEARS).map(Number).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;
}

View file

@ -0,0 +1,280 @@
/**
* 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';
// 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 estimatedPaid = 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
const annualEstimateNeeded = Math.max(0, totalFederalTax - withholding);
const perQuarter = annualEstimateNeeded / 4;
const safeHarborPerQuarter =
safeHarborAmount != null ? Math.max(0, safeHarborAmount - withholding) / 4 : null;
const asOfISO = asOf.toISOString().slice(0, 10);
const quarterlySchedule: QuarterlyPayment[] = data.quarterlyDueDates.map(
(dueDate, i) => ({
quarter: (i + 1) as 1 | 2 | 3 | 4,
dueDate,
projectedAmount: round2(perQuarter),
safeHarborAmount: safeHarborPerQuarter != null ? round2(safeHarborPerQuarter) : null,
isPastDue: dueDate < asOfISO,
}),
);
// ─── 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,
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();
}
function fractionOfYearElapsed(year: number, asOf: Date): number {
if (asOf.getFullYear() < year) return 0;
if (asOf.getFullYear() > year) return 1;
const start = new Date(year, 0, 1).getTime();
const end = new Date(year + 1, 0, 1).getTime();
return (asOf.getTime() - start) / (end - start);
}

14
client/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>,
);

View file

@ -0,0 +1,156 @@
/**
* 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> = {
ytdPayments: 'YTD Payments',
ytdExpenses: 'YTD Expenses',
ytdNet: 'YTD Net Income',
nextQuarterlyDue: 'Next Quarterly Due',
projectedAnnualTax: 'Projected Annual Tax',
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 };
const taxResult = useMemo(
() => calculateTax(data.payments, data.expenses, taxInputs, { project: true }),
[data.payments, data.expenses, taxInputs],
);
const nextQuarter = taxResult.quarterlySchedule.find((q) => !q.isPastDue);
const widgets: Record<DashboardWidget, { value: string; sub?: string; className?: string }> = {
ytdPayments: {
value: fmtMoneyShort(currentYearStats?.payments ?? 0),
sub: currentYearStats?.projected != null
? `proj: ${fmtMoneyShort(currentYearStats.payments + (currentYearStats.projected - currentYearStats.net))}`
: undefined,
},
ytdExpenses: {
value: fmtMoneyShort(currentYearStats?.expenses ?? 0),
className: 'negative',
},
ytdNet: {
value: fmtMoneyShort(currentYearStats?.net ?? 0),
sub: currentYearStats?.projected != null
? `proj: ${fmtMoneyShort(currentYearStats.projected)}`
: undefined,
className: (currentYearStats?.net ?? 0) >= 0 ? 'positive' : 'negative',
},
nextQuarterlyDue: {
value: nextQuarter ? fmtMoneyShort(nextQuarter.projectedAmount) : '—',
sub: nextQuarter ? `Q${nextQuarter.quarter} due ${nextQuarter.dueDate}` : 'All paid',
},
projectedAnnualTax: {
value: fmtMoneyShort(taxResult.totalFederalTax),
sub: `${fmtMoneyShort(taxResult.remainingDue)} still due`,
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 style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: 16, flex: 1, minHeight: 0 }}>
{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>
);
}

View file

@ -0,0 +1,320 @@
/**
* Shared Ledger page tabs for Work Log, Payments, Expenses.
* Left: hierarchical spreadsheet + entry form + summary stats.
* Right: configurable charts.
*/
import { useMemo, useState } from 'react';
import { useAppStore } from '@/store/appStore';
import type { HierNode, WorkEntry, Payment, Expense } 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 { Modal, ConfirmDialog } from '@/components/common/Modal';
import { buildHierarchy, aggregate } from '@/lib/stats/aggregate';
import { fmtMoney } from '@/lib/format';
type Tab = 'work' | 'payments' | 'expenses';
export function LedgerPage({ initialTab = 'work' }: { initialTab?: Tab }) {
const [tab, setTab] = useState<Tab>(initialTab);
return (
<div className="split-layout">
<div className="left">
<div className="tabs">
<button className={`tab ${tab === 'work' ? 'active' : ''}`} onClick={() => setTab('work')}>
Work Log
</button>
<button className={`tab ${tab === 'payments' ? 'active' : ''}`} onClick={() => setTab('payments')}>
Payments
</button>
<button className={`tab ${tab === 'expenses' ? 'active' : ''}`} onClick={() => setTab('expenses')}>
Expenses
</button>
</div>
{tab === 'work' && <WorkTab />}
{tab === 'payments' && <PaymentsTab />}
{tab === 'expenses' && <ExpensesTab />}
</div>
<div className="right">
<ChartSidebar />
</div>
</div>
);
}
// ─── Work Tab ────────────────────────────────────────────────────────────────
function WorkTab() {
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 [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<WorkEntry | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const nodes = useMemo(
() =>
buildHierarchy(
entries,
(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],
);
const stats = useMemo(() => aggregate(entries, [], []), [entries]);
return (
<>
<PeriodSummaryRow stats={stats} metric="workValue" label="Work value" />
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div className="card-header">
<span className="card-title">Work Log</span>
<button className="btn btn-primary btn-sm" onClick={() => setShowAdd(true)}>
+ Add entry
</button>
</div>
<HierSpreadsheet
nodes={nodes}
valueLabel="Earned"
onEdit={(n) => setEditing(n.entry as WorkEntry)}
onDelete={(n) => setDeleting(n.entry!.id)}
/>
</div>
<Modal open={showAdd} title="Add work entry" onClose={() => setShowAdd(false)}>
<WorkEntryForm
defaultRate={defaultRate}
onSubmit={(d) => { addWorkEntry(d); setShowAdd(false); }}
onCancel={() => setShowAdd(false)}
/>
</Modal>
<Modal open={editing != null} title="Edit work entry" onClose={() => setEditing(null)}>
{editing && (
<WorkEntryForm
initial={editing}
defaultRate={defaultRate}
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)}
/>
</>
);
}
// ─── Payments Tab ────────────────────────────────────────────────────────────
function PaymentsTab() {
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 [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<Payment | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const nodes = useMemo(
() =>
buildHierarchy(
payments,
(e) => (e as Payment).amount,
(e) => {
const p = e as Payment;
return `${p.payer} (${p.form ?? 'direct'})${p.notes ? `${p.notes}` : ''}`;
},
),
[payments],
);
const stats = useMemo(() => aggregate([], payments, []), [payments]);
return (
<>
<PeriodSummaryRow stats={stats} metric="payments" label="Taxable income" />
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div className="card-header">
<span className="card-title">Payments Received</span>
<button className="btn btn-primary btn-sm" onClick={() => setShowAdd(true)}>
+ Add payment
</button>
</div>
<HierSpreadsheet
nodes={nodes}
valueLabel="Amount"
onEdit={(n) => setEditing(n.entry as Payment)}
onDelete={(n) => setDeleting(n.entry!.id)}
/>
</div>
<Modal open={showAdd} title="Add payment" onClose={() => setShowAdd(false)}>
<PaymentForm onSubmit={(d) => { addPayment(d); setShowAdd(false); }} onCancel={() => setShowAdd(false)} />
</Modal>
<Modal open={editing != null} title="Edit payment" onClose={() => setEditing(null)}>
{editing && (
<PaymentForm
initial={editing}
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)}
/>
</>
);
}
// ─── Expenses Tab ────────────────────────────────────────────────────────────
function ExpensesTab() {
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 [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<Expense | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const nodes = useMemo(
() =>
buildHierarchy(
expenses,
(e) => (e as Expense).amount,
(e) => {
const x = e as Expense;
return `${x.description}${x.deductible ? ' ✓ deductible' : ''}${x.category ? `${x.category}` : ''}`;
},
),
[expenses],
);
const stats = useMemo(() => aggregate([], [], expenses), [expenses]);
return (
<>
<PeriodSummaryRow stats={stats} metric="expenses" label="Expenses" />
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div className="card-header">
<span className="card-title">Expenses</span>
<button className="btn btn-primary btn-sm" onClick={() => setShowAdd(true)}>
+ Add expense
</button>
</div>
<HierSpreadsheet
nodes={nodes}
valueLabel="Amount"
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)}
/>
</>
);
}
// ─── Period summary strip (year/month/day totals + avg + projection) ─────────
function PeriodSummaryRow({
stats,
metric,
label,
}: {
stats: ReturnType<typeof aggregate>;
metric: 'workValue' | 'payments' | 'expenses';
label: string;
}) {
const currentYear = String(new Date().getFullYear());
const currentMonth = new Date().toISOString().slice(0, 7);
const today = new Date().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);
return (
<div className="stat-grid">
<div className="stat-card">
<div className="stat-label">YTD {label}</div>
<div className="stat-value">{fmtMoney(y?.[metric] ?? 0)}</div>
<div className="stat-sub">
avg/mo: {fmtMoney(y?.avgPerChild)} ·
proj: {fmtMoney(y?.projected)}
</div>
</div>
<div className="stat-card">
<div className="stat-label">This month</div>
<div className="stat-value">{fmtMoney(m?.[metric] ?? 0)}</div>
<div className="stat-sub">
avg/day: {fmtMoney(m?.avgPerChild)} ·
proj: {fmtMoney(m?.projected)}
</div>
</div>
<div className="stat-card">
<div className="stat-label">Today</div>
<div className="stat-value">{fmtMoney(d?.[metric] ?? 0)}</div>
<div className="stat-sub">&nbsp;</div>
</div>
</div>
);
}

View file

@ -0,0 +1,240 @@
/**
* Settings themes, storage mode, cloud config, default rate.
*/
import { useState } from 'react';
import { useAppStore } from '@/store/appStore';
import { THEME_NAMES } from '@/themes/ThemeProvider';
import type { ThemeName, ThemeMode, StorageMode } from '@/types';
export function SettingsPage() {
const settings = useAppStore((s) => s.data.settings);
const cloudAuth = useAppStore((s) => s.cloudAuth);
const setTheme = useAppStore((s) => s.setTheme);
const setStorageMode = useAppStore((s) => s.setStorageMode);
const setDefaultRate = useAppStore((s) => s.setDefaultRate);
const exportFile = useAppStore((s) => s.exportFile);
const importFile = useAppStore((s) => s.importFile);
const logout = useAppStore((s) => s.logout);
const [cloudUrl, setCloudUrl] = useState(settings.cloudConfig?.apiUrl ?? '');
const [importPwd, setImportPwd] = useState('');
const [importFileObj, setImportFileObj] = useState<File | null>(null);
const handleImport = async () => {
if (!importFileObj || !importPwd) return;
try {
await importFile(importFileObj, importPwd);
alert('Import successful');
setImportFileObj(null);
setImportPwd('');
} catch (e) {
alert(`Import failed: ${e instanceof Error ? e.message : e}`);
}
};
return (
<div className="flex-col gap-4" style={{ maxWidth: 720 }}>
<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>
{/* Theme swatches */}
<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>
{/* ─── Storage ───────────────────────────────────────────────────── */}
<div className="card">
<div className="card-header"><span className="card-title">Data Storage</span></div>
<div className="btn-group mb-4">
{(['cookie', 'file', 'cloud'] as StorageMode[]).map((m) => (
<button
key={m}
className={`btn ${settings.storageMode === m ? 'active' : ''}`}
onClick={() => {
if (m === 'cloud') {
if (!cloudUrl) {
alert('Enter a cloud API URL below first');
return;
}
setStorageMode('cloud', { apiUrl: cloudUrl });
} else {
setStorageMode(m);
}
}}
>
{m === 'cookie' && '🍪 Browser Cookies'}
{m === 'file' && '📁 Local File'}
{m === 'cloud' && '☁ Cloud'}
</button>
))}
</div>
{settings.storageMode === 'cookie' && (
<p className="text-sm text-muted">
Encrypted data is chunked into browser cookies. Survives page refresh
but tied to this browser on this device.
</p>
)}
{settings.storageMode === 'file' && (
<p className="text-sm text-muted">
Each save triggers a download of an encrypted <code>.t99</code> file.
You can import it on any device.
</p>
)}
{/* Cloud config */}
<div className="field mt-4">
<label>Cloud API URL (MongoDB-backed ten99timecard-server)</label>
<input
className="input"
value={cloudUrl}
onChange={(e) => setCloudUrl(e.target.value)}
placeholder="https://your-server.example.com"
/>
</div>
{cloudAuth.token && (
<div className="text-sm text-success mt-2">
Authenticated as {cloudAuth.email} via {cloudAuth.provider}
</div>
)}
<p className="text-sm text-muted mt-2">
Your data is encrypted with your password BEFORE it reaches the cloud.
The server only ever sees encrypted blobs.
</p>
</div>
{/* ─── Import / Export ───────────────────────────────────────────── */}
<div className="card">
<div className="card-header"><span className="card-title">Import / Export</span></div>
<div className="flex gap-2 mb-4">
<button className="btn" onClick={exportFile}>
Export encrypted backup
</button>
</div>
<div className="flex-col gap-2">
<div className="field">
<label>Import from file</label>
<input
type="file"
accept=".t99"
onChange={(e) => setImportFileObj(e.target.files?.[0] ?? null)}
/>
</div>
<div className="field">
<label>File password</label>
<input
type="password"
className="input"
value={importPwd}
onChange={(e) => setImportPwd(e.target.value)}
/>
</div>
<button
className="btn btn-primary"
onClick={handleImport}
disabled={!importFileObj || !importPwd}
>
Import
</button>
</div>
</div>
{/* ─── Account ───────────────────────────────────────────────────── */}
<div className="card">
<div className="card-header"><span className="card-title">Account</span></div>
<button className="btn btn-danger" onClick={logout}>
Lock & Log Out
</button>
</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>
);
}

View file

@ -0,0 +1,249 @@
/**
* Tax page calculations, projections, quarterly schedule,
* and highlighted prompts for missing inputs.
*/
import { 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 } from '@/types';
import { fmtMoney, fmtMoneyShort } from '@/lib/format';
import { ChartSidebar } from '@/components/charts/ChartSidebar';
const FILING_LABELS: Record<FilingStatus, string> = {
single: 'Single',
mfj: 'Married filing jointly',
mfs: 'Married filing separately',
hoh: 'Head of household',
};
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 years = availableTaxYears();
const [year, setYear] = useState(years[0] ?? new Date().getFullYear());
const [project, setProject] = useState(true);
const inputs: TaxInputs = taxInputs[year] ?? { taxYear: year, filingStatus: 'single' };
const result = useMemo(
() => calculateTax(payments, expenses, inputs, { project }),
[payments, expenses, inputs, project],
);
const updateInput = (patch: Partial<TaxInputs>) => setTaxInputs(year, patch);
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>
<label className="checkbox">
<input type="checkbox" checked={project} onChange={(e) => setProject(e.target.checked)} />
Project full year
</label>
</div>
</div>
{/* ─── Highlighted prompts for missing data ───────────────────── */}
{result.prompts.length > 0 && (
<div className="flex-col gap-2">
{result.prompts.map((p) => (
<div key={p.field} className="field-prompt">
<div className="field">
<label>{p.label}</label>
<input
type="number"
step="0.01"
className="input"
placeholder="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>
)}
{/* ─── Bottom-line summary ─────────────────────────────────────── */}
<div className="stat-grid">
<div className="stat-card">
<div className="stat-label">Total Federal Tax</div>
<div className="stat-value">{fmtMoneyShort(result.totalFederalTax)}</div>
<div className="stat-sub">Income + SE tax</div>
</div>
<div className="stat-card">
<div className="stat-label">Already Paid</div>
<div className="stat-value text-success">{fmtMoneyShort(result.alreadyPaid)}</div>
<div className="stat-sub">Withholding + estimates</div>
</div>
<div className="stat-card negative">
<div className="stat-label">Remaining Due</div>
<div className="stat-value">{fmtMoneyShort(result.remainingDue)}</div>
<div className="stat-sub">
{result.safeHarborMet === true && '✓ Safe harbor met'}
{result.safeHarborMet === false && '⚠ Safe harbor NOT met'}
{result.safeHarborMet === null && 'Safe harbor: need prior year data'}
</div>
</div>
</div>
{/* ─── 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">Projected</th>
<th className="num">Safe Harbor Min</th>
<th></th>
</tr>
</thead>
<tbody>
{result.quarterlySchedule.map((q) => (
<tr key={q.quarter}>
<td>Q{q.quarter}</td>
<td>{q.dueDate}</td>
<td className="num">{fmtMoney(q.projectedAmount)}</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>
</div>
{/* ─── Full breakdown ──────────────────────────────────────────── */}
<div className="card">
<div className="card-header"><span className="card-title">Calculation Breakdown</span></div>
<table className="data-table">
<tbody>
<BreakdownRow label="Gross receipts (payments)" value={result.grossReceipts} />
<BreakdownRow label=" Deductible expenses" value={-result.deductibleExpenses} />
<BreakdownRow label="= Net profit (Schedule C)" value={result.netProfit} bold />
<BreakdownSep />
<BreakdownRow label="SE taxable base (× 92.35%)" value={result.seTaxableBase} />
<BreakdownRow label="Social Security tax (12.4%, capped)" value={result.socialSecurityTax} />
<BreakdownRow label="Medicare tax (2.9%)" value={result.medicareTax} />
<BreakdownRow label="Additional Medicare (0.9%)" value={result.additionalMedicareTax} />
<BreakdownRow label="= Total SE tax" value={result.totalSETax} bold />
<BreakdownSep />
<BreakdownRow label=" ½ SE tax deduction" value={-result.seTaxDeduction} />
<BreakdownRow label=" Standard deduction" value={-result.standardDeduction} />
<BreakdownRow label=" QBI deduction (§199A)" value={-result.qbiDeduction} />
<BreakdownRow label="= Taxable income" value={result.taxableIncome} bold />
<BreakdownRow label="Federal income tax" value={result.federalIncomeTax} bold />
<BreakdownSep />
<BreakdownRow label="TOTAL FEDERAL TAX" value={result.totalFederalTax} bold highlight />
</tbody>
</table>
{result.notes.length > 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 />
</div>
</div>
);
}
function BreakdownRow({ label, value, bold, highlight }: { label: string; value: 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>
</tr>
);
}
function BreakdownSep() {
return <tr><td colSpan={2} style={{ padding: 2, background: 'var(--bg-elev-2)' }}></td></tr>;
}

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">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">{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">{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>
);
}

View file

@ -0,0 +1,404 @@
/**
* 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 and chosen storage adapter.
*/
import { create } from 'zustand';
import type {
AppData,
WorkEntry,
Payment,
Expense,
TaxInputs,
Settings,
DashboardConfig,
ChartConfig,
ThemeName,
ThemeMode,
StorageMode,
CloudConfig,
LocalAuthState,
CloudAuthState,
} from '@/types';
import { uid } from '@/lib/id';
import { Vault, cookieDataExists } from '@/lib/storage/vault';
import { todayISO } from '@/lib/format';
// ─── Defaults ────────────────────────────────────────────────────────────────
const defaultDashboard = (): DashboardConfig => ({
charts: [
{
id: uid(),
type: 'area',
metrics: ['payments', 'expenses'],
granularity: 'month',
rangeStart: null,
rangeEnd: null,
yMin: null,
yMax: null,
title: 'Income vs Expenses',
},
],
widgets: [
'ytdPayments',
'ytdNet',
'nextQuarterlyDue',
'projectedAnnualTax',
],
});
const defaultSettings = (): Settings => ({
theme: 'standard',
mode: 'dark',
storageMode: 'cookie',
defaultRate: 50,
});
const defaultData = (): AppData => ({
workEntries: [],
payments: [],
expenses: [],
taxInputs: {},
dashboard: defaultDashboard(),
settings: defaultSettings(),
version: 1,
});
// ─── Store shape ─────────────────────────────────────────────────────────────
interface AppStore {
// Data
data: AppData;
// Auth
localAuth: LocalAuthState;
cloudAuth: CloudAuthState;
// Internals
vault: Vault | null;
saving: boolean;
lastSaveError: string | null;
// ─── Auth actions ─────────────────────────────────────────────────────────
/** Create a new local vault (cookie mode). Fails if username already taken. */
register: (username: string, password: string) => Promise<void>;
/** Unlock existing cookie vault OR create if none exists. */
login: (username: string, password: string) => Promise<void>;
logout: () => void;
/** Cloud auth — sets JWT + switches to cloud mode */
setCloudAuth: (token: string, email: string, provider: 'email' | 'google') => 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;
// ─── Tax inputs ───────────────────────────────────────────────────────────
setTaxInputs: (year: number, inputs: Partial<TaxInputs>) => void;
// ─── Dashboard ────────────────────────────────────────────────────────────
addChart: (c?: Partial<ChartConfig>) => void;
updateChart: (id: string, patch: Partial<ChartConfig>) => void;
removeChart: (id: string) => void;
setDashboardWidgets: (w: DashboardConfig['widgets']) => void;
// ─── Settings ─────────────────────────────────────────────────────────────
setTheme: (theme: ThemeName, mode: ThemeMode) => void;
setStorageMode: (mode: StorageMode, cloudConfig?: CloudConfig) => void;
setDefaultRate: (rate: number) => void;
// ─── File import/export ───────────────────────────────────────────────────
exportFile: () => Promise<void>;
importFile: (file: File, password: string) => Promise<void>;
// ─── Persistence ──────────────────────────────────────────────────────────
persist: () => Promise<void>;
}
// ─── Implementation ──────────────────────────────────────────────────────────
export const useAppStore = create<AppStore>((set, get) => {
/** Debounced persist — avoid thrashing PBKDF2 on every keystroke */
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(),
localAuth: { unlocked: false, username: null },
cloudAuth: { token: null, email: null, provider: null },
vault: null,
saving: false,
lastSaveError: null,
// ─── Auth ───────────────────────────────────────────────────────────────
register: async (username, password) => {
if (await cookieDataExists(username)) {
throw new Error('That username already has a local vault. Try logging in instead.');
}
const vault = new Vault({ mode: 'cookie', username, password });
const fresh = defaultData();
await vault.save(fresh);
set({
vault,
data: fresh,
localAuth: { unlocked: true, username },
});
},
login: async (username, password) => {
const exists = await cookieDataExists(username);
const vault = new Vault({ mode: 'cookie', username, password });
if (!exists) {
// First time — create vault
const fresh = defaultData();
await vault.save(fresh);
set({
vault,
data: fresh,
localAuth: { unlocked: true, username },
});
return;
}
// Existing — try to decrypt. Wrong password throws.
let data: AppData;
try {
data = (await vault.load()) ?? defaultData();
} catch {
throw new Error('Wrong password');
}
set({
vault,
data,
localAuth: { unlocked: true, username },
});
},
logout: () => {
set({
vault: null,
data: defaultData(),
localAuth: { unlocked: false, username: null },
cloudAuth: { token: null, email: null, provider: null },
});
},
setCloudAuth: (token, email, provider) => {
set({ cloudAuth: { token, email, provider } });
},
// ─── 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);
});
},
// ─── 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',
};
});
},
// ─── Dashboard ──────────────────────────────────────────────────────────
addChart: (c) => {
mutate((d) => {
d.dashboard.charts.push({
id: uid(),
type: 'line',
metrics: ['payments'],
granularity: 'month',
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;
});
},
// ─── Settings ───────────────────────────────────────────────────────────
setTheme: (theme, mode) => {
mutate((d) => {
d.settings.theme = theme;
d.settings.mode = mode;
});
},
setStorageMode: (mode, cloudConfig) => {
const { localAuth, cloudAuth } = get();
if (!localAuth.username) throw new Error('Must be logged in');
mutate((d) => {
d.settings.storageMode = mode;
if (cloudConfig) d.settings.cloudConfig = cloudConfig;
});
// Rebuild vault with new adapter
const password = prompt('Re-enter your password to switch storage mode:');
if (!password) return;
const vault = new Vault({
mode,
username: localAuth.username,
password,
apiUrl: cloudConfig?.apiUrl,
getCloudToken: () => cloudAuth.token,
});
set({ vault });
schedulePersist();
},
setDefaultRate: (rate) => {
mutate((d) => {
d.settings.defaultRate = rate;
});
},
// ─── File import/export ─────────────────────────────────────────────────
exportFile: async () => {
const { vault, data } = get();
if (!vault) throw new Error('Not logged in');
await vault.exportToFile(data);
},
importFile: async (file, password) => {
const tmpVault = new Vault({
mode: 'file',
username: 'import',
password,
});
tmpVault.fileAdapter.setFile(file);
const data = await tmpVault.load();
if (!data) throw new Error('File empty or unreadable');
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),
});
}
},
};
});

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);
}

View file

@ -0,0 +1,475 @@
@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;
overflow-x: auto;
}
.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;
}
/* Two-column: data left, charts right */
.split-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px;
height: 100%;
}
.split-layout > .left,
.split-layout > .right {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
overflow: auto;
}
/* ─── 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; }
/* ─── 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);
position: sticky;
top: 0;
}
.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 { cursor: pointer; 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; cursor: default; }
.hier-toggle {
display: inline-flex;
width: 16px;
margin-right: 4px;
color: var(--fg-muted);
transition: transform var(--transition);
}
.hier-toggle.expanded { transform: rotate(90deg); }
.indent-0 { padding-left: 12px; }
.indent-1 { padding-left: 32px; }
.indent-2 { padding-left: 52px; }
.indent-3 { padding-left: 72px; }
/* ─── 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); }
/* ─── Responsive ──────────────────────────────────────────────────────────── */
/* Tablets */
@media (max-width: 1024px) {
.split-layout {
grid-template-columns: 1fr;
height: auto;
}
.app-body { padding: 16px; }
.timer-display { font-size: 44px; }
}
/* Phones */
@media (max-width: 640px) {
.app-header {
flex-wrap: wrap;
height: auto;
padding: 10px 12px;
}
.app-nav {
width: 100%;
order: 3;
}
.app-body { padding: 12px; }
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.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; }
}
/* 4K TV */
@media (min-width: 2400px) {
body { font-size: 16px; }
.app-header { height: 72px; padding: 0 40px; }
.app-body { padding: 40px; max-width: 2800px; margin: 0 auto; }
.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%; }

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;
}

344
client/src/types/index.ts Normal file
View file

@ -0,0 +1,344 @@
// ============================================================================
// 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;
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 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;
/** Estimated payments already made this year */
estimatedPaymentsMade?: number;
/** 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;
// 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;
/** Recommended payment based on projected annual liability */
projectedAmount: 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;
}
export interface DashboardConfig {
charts: ChartConfig[];
/** Which stat widgets to show at top of dashboard */
widgets: DashboardWidget[];
}
export type DashboardWidget =
| 'ytdPayments'
| 'ytdExpenses'
| 'ytdNet'
| 'nextQuarterlyDue'
| 'projectedAnnualTax'
| 'avgMonthlyNet'
| 'avgDailyWork';
// ─── Themes ──────────────────────────────────────────────────────────────────
export type ThemeName =
| 'standard'
| 'sakura'
| 'pumpkin'
| 'fall'
| 'aqua'
| 'lavender'
| 'comic'
| 'manga'
| 'highcontrast'
| 'cyberpunk';
export type ThemeMode = 'light' | 'dark';
// ─── Settings & App State ────────────────────────────────────────────────────
export type StorageMode = 'cookie' | 'file' | 'cloud';
export interface CloudConfig {
/** API base URL (points at ten99timecard-server or compatible) */
apiUrl: string;
}
export interface Settings {
theme: ThemeName;
mode: ThemeMode;
storageMode: StorageMode;
cloudConfig?: CloudConfig;
/** 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[];
taxInputs: Record<number, TaxInputs>; // keyed by tax year
dashboard: DashboardConfig;
settings: Settings;
/** Monotonically increasing for optimistic cloud sync */
version: number;
}
// ─── Auth ────────────────────────────────────────────────────────────────────
export interface LocalAuthState {
/** PBKDF2-derived key used to encrypt/decrypt AppData */
unlocked: boolean;
/** Username for display / cookie namespacing */
username: string | null;
}
export interface CloudAuthState {
token: string | null;
email: string | null;
provider: 'email' | 'google' | null;
}
// ─── 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
client/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"]
}

View file

@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/__tests__/appStore.test.ts","./src/__tests__/components.test.tsx","./src/__tests__/crypto.test.ts","./src/__tests__/format.test.ts","./src/__tests__/setup.ts","./src/__tests__/stats.test.ts","./src/__tests__/storage.test.ts","./src/__tests__/tax.test.ts","./src/__tests__/timerStore.test.ts","./src/components/auth/LoginScreen.tsx","./src/components/charts/ChartPanel.tsx","./src/components/charts/ChartSidebar.tsx","./src/components/common/Modal.tsx","./src/components/spreadsheet/EntryForm.tsx","./src/components/spreadsheet/HierSpreadsheet.tsx","./src/lib/format.ts","./src/lib/id.ts","./src/lib/crypto/encryption.ts","./src/lib/stats/aggregate.ts","./src/lib/storage/adapters.ts","./src/lib/storage/vault.ts","./src/lib/tax/brackets.ts","./src/lib/tax/calculate.ts","./src/pages/DashboardPage.tsx","./src/pages/LedgerPage.tsx","./src/pages/SettingsPage.tsx","./src/pages/TaxPage.tsx","./src/pages/TimerPage.tsx","./src/store/appStore.ts","./src/store/timerStore.ts","./src/themes/ThemeProvider.tsx","./src/types/index.ts"],"version":"5.9.3"}

27
client/vite.config.ts Normal file
View file

@ -0,0 +1,27 @@
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,
proxy: {
'/api': { target: 'http://localhost:4000', changeOrigin: true },
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/__tests__/setup.ts',
css: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['**/*.d.ts', '**/__tests__/**', '**/main.tsx'],
},
},
});