Old files removed
This commit is contained in:
parent
99a3dbd73c
commit
a9c8d16f05
52 changed files with 0 additions and 7641 deletions
|
|
@ -1,15 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,282 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,245 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,300 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,234 +0,0 @@
|
||||||
/**
|
|
||||||
* 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> </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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
/** 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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
/** 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}`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,289 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,280 +0,0 @@
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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>,
|
|
||||||
);
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
/**
|
|
||||||
* 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"> </div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,373 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,404 +0,0 @@
|
||||||
/**
|
|
||||||
* 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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,475 +0,0 @@
|
||||||
@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%; }
|
|
||||||
|
|
@ -1,361 +0,0 @@
|
||||||
/* ============================================================================
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,344 +0,0 @@
|
||||||
// ============================================================================
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"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"}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
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'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
# Server
|
|
||||||
PORT=4000
|
|
||||||
CORS_ORIGIN=http://localhost:5173
|
|
||||||
|
|
||||||
# MongoDB
|
|
||||||
MONGO_URI=mongodb://localhost:27017
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
JWT_SECRET=change-me-in-production-use-a-long-random-string
|
|
||||||
|
|
||||||
# Google OAuth (optional — email auth works without these)
|
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
GOOGLE_CALLBACK_URL=http://localhost:4000/api/auth/google/callback
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
{
|
|
||||||
"name": "ten99timecard-server",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "tsx watch src/index.ts",
|
|
||||||
"build": "tsc",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"test": "vitest run"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^4.21.1",
|
|
||||||
"mongodb": "^6.10.0",
|
|
||||||
"bcrypt": "^5.1.1",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"passport": "^0.7.0",
|
|
||||||
"passport-google-oauth20": "^2.0.0",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^16.4.5",
|
|
||||||
"zod": "^3.23.8"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/bcrypt": "^5.0.2",
|
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
|
||||||
"@types/passport": "^1.0.16",
|
|
||||||
"@types/passport-google-oauth20": "^2.0.16",
|
|
||||||
"@types/cors": "^2.8.17",
|
|
||||||
"@types/node": "^22.9.0",
|
|
||||||
"@types/supertest": "^6.0.2",
|
|
||||||
"typescript": "^5.6.3",
|
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"vitest": "^2.1.4",
|
|
||||||
"supertest": "^7.0.0",
|
|
||||||
"mongodb-memory-server": "^10.1.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
|
||||||
import { connect, disconnect, users, blobs } from '../db/mongo.js';
|
|
||||||
import { createApp } from '../app.js';
|
|
||||||
import { signToken, verifyToken } from '../middleware/auth.js';
|
|
||||||
|
|
||||||
let mongod: MongoMemoryServer;
|
|
||||||
let app: ReturnType<typeof createApp>;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
mongod = await MongoMemoryServer.create();
|
|
||||||
await connect(mongod.getUri(), 'test');
|
|
||||||
app = createApp();
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await disconnect();
|
|
||||||
await mongod.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await users().deleteMany({});
|
|
||||||
await blobs().deleteMany({});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Health ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('GET /api/health', () => {
|
|
||||||
it('returns ok', async () => {
|
|
||||||
const res = await request(app).get('/api/health');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.ok).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/auth/signup', () => {
|
|
||||||
it('creates account and returns token', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/auth/signup')
|
|
||||||
.send({ email: 'test@example.com', password: 'longpassword' });
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
expect(res.body.token).toBeTruthy();
|
|
||||||
expect(res.body.email).toBe('test@example.com');
|
|
||||||
|
|
||||||
const payload = verifyToken(res.body.token);
|
|
||||||
expect(payload.email).toBe('test@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects duplicate email', async () => {
|
|
||||||
await request(app).post('/api/auth/signup').send({ email: 'dup@x.com', password: 'longpassword' });
|
|
||||||
const res = await request(app).post('/api/auth/signup').send({ email: 'dup@x.com', password: 'otherpass123' });
|
|
||||||
expect(res.status).toBe(409);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid email', async () => {
|
|
||||||
const res = await request(app).post('/api/auth/signup').send({ email: 'not-an-email', password: 'longpassword' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects short password', async () => {
|
|
||||||
const res = await request(app).post('/api/auth/signup').send({ email: 'x@y.com', password: 'short' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hashes password (never stores plaintext)', async () => {
|
|
||||||
await request(app).post('/api/auth/signup').send({ email: 'hash@x.com', password: 'my-secret-password' });
|
|
||||||
const user = await users().findOne({ email: 'hash@x.com' });
|
|
||||||
expect(user?.passwordHash).toBeTruthy();
|
|
||||||
expect(user?.passwordHash).not.toContain('my-secret-password');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /api/auth/login', () => {
|
|
||||||
it('succeeds with correct credentials', async () => {
|
|
||||||
await request(app).post('/api/auth/signup').send({ email: 'a@b.com', password: 'correctpass' });
|
|
||||||
const res = await request(app).post('/api/auth/login').send({ email: 'a@b.com', password: 'correctpass' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.token).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails with wrong password', async () => {
|
|
||||||
await request(app).post('/api/auth/signup').send({ email: 'a@b.com', password: 'correctpass' });
|
|
||||||
const res = await request(app).post('/api/auth/login').send({ email: 'a@b.com', password: 'wrongpassword' });
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails with unknown email', async () => {
|
|
||||||
const res = await request(app).post('/api/auth/login').send({ email: 'ghost@x.com', password: 'whateverpass' });
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not leak whether email exists', async () => {
|
|
||||||
await request(app).post('/api/auth/signup').send({ email: 'real@x.com', password: 'realpassword' });
|
|
||||||
const r1 = await request(app).post('/api/auth/login').send({ email: 'real@x.com', password: 'wrongggggg' });
|
|
||||||
const r2 = await request(app).post('/api/auth/login').send({ email: 'fake@x.com', password: 'wrongggggg' });
|
|
||||||
expect(r1.body.error).toBe(r2.body.error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Data blob ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('/api/data', () => {
|
|
||||||
const getToken = async () => {
|
|
||||||
const r = await request(app).post('/api/auth/signup').send({ email: 'd@x.com', password: 'datapassword' });
|
|
||||||
return r.body.token as string;
|
|
||||||
};
|
|
||||||
|
|
||||||
it('rejects without auth', async () => {
|
|
||||||
const res = await request(app).get('/api/data');
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid token', async () => {
|
|
||||||
const res = await request(app).get('/api/data').set('Authorization', 'Bearer invalid.token.here');
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('404 when no blob saved yet', async () => {
|
|
||||||
const token = await getToken();
|
|
||||||
const res = await request(app).get('/api/data').set('Authorization', `Bearer ${token}`);
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PUT then GET returns blob', async () => {
|
|
||||||
const token = await getToken();
|
|
||||||
const blob = 'encrypted-base64-ciphertext-goes-here';
|
|
||||||
const put = await request(app)
|
|
||||||
.put('/api/data')
|
|
||||||
.set('Authorization', `Bearer ${token}`)
|
|
||||||
.send({ blob });
|
|
||||||
expect(put.status).toBe(200);
|
|
||||||
|
|
||||||
const get = await request(app).get('/api/data').set('Authorization', `Bearer ${token}`);
|
|
||||||
expect(get.status).toBe(200);
|
|
||||||
expect(get.body.blob).toBe(blob);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PUT overwrites previous blob (upsert)', async () => {
|
|
||||||
const token = await getToken();
|
|
||||||
await request(app).put('/api/data').set('Authorization', `Bearer ${token}`).send({ blob: 'v1' });
|
|
||||||
await request(app).put('/api/data').set('Authorization', `Bearer ${token}`).send({ blob: 'v2' });
|
|
||||||
const get = await request(app).get('/api/data').set('Authorization', `Bearer ${token}`);
|
|
||||||
expect(get.body.blob).toBe('v2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DELETE removes blob', async () => {
|
|
||||||
const token = await getToken();
|
|
||||||
await request(app).put('/api/data').set('Authorization', `Bearer ${token}`).send({ blob: 'x' });
|
|
||||||
await request(app).delete('/api/data').set('Authorization', `Bearer ${token}`);
|
|
||||||
const get = await request(app).get('/api/data').set('Authorization', `Bearer ${token}`);
|
|
||||||
expect(get.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isolates blobs per user', async () => {
|
|
||||||
const r1 = await request(app).post('/api/auth/signup').send({ email: 'u1@x.com', password: 'password1234' });
|
|
||||||
const r2 = await request(app).post('/api/auth/signup').send({ email: 'u2@x.com', password: 'password1234' });
|
|
||||||
|
|
||||||
await request(app).put('/api/data').set('Authorization', `Bearer ${r1.body.token}`).send({ blob: 'alice-blob' });
|
|
||||||
await request(app).put('/api/data').set('Authorization', `Bearer ${r2.body.token}`).send({ blob: 'bob-blob' });
|
|
||||||
|
|
||||||
const g1 = await request(app).get('/api/data').set('Authorization', `Bearer ${r1.body.token}`);
|
|
||||||
const g2 = await request(app).get('/api/data').set('Authorization', `Bearer ${r2.body.token}`);
|
|
||||||
expect(g1.body.blob).toBe('alice-blob');
|
|
||||||
expect(g2.body.blob).toBe('bob-blob');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PUT rejects missing blob', async () => {
|
|
||||||
const token = await getToken();
|
|
||||||
const res = await request(app).put('/api/data').set('Authorization', `Bearer ${token}`).send({});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── JWT ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('JWT helpers', () => {
|
|
||||||
it('signs and verifies round-trip', () => {
|
|
||||||
const token = signToken({ userId: '123', email: 'x@y.com' });
|
|
||||||
const payload = verifyToken(token);
|
|
||||||
expect(payload.userId).toBe('123');
|
|
||||||
expect(payload.email).toBe('x@y.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects tampered token', () => {
|
|
||||||
const token = signToken({ userId: '123', email: 'x@y.com' });
|
|
||||||
const tampered = token.slice(0, -5) + 'XXXXX';
|
|
||||||
expect(() => verifyToken(tampered)).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import express from 'express';
|
|
||||||
import cors from 'cors';
|
|
||||||
import passport from 'passport';
|
|
||||||
import authRoutes, { configureGoogleStrategy } from './routes/auth.js';
|
|
||||||
import dataRoutes from './routes/data.js';
|
|
||||||
|
|
||||||
export function createApp() {
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
app.use(cors({ origin: process.env.CORS_ORIGIN || true }));
|
|
||||||
app.use(express.json({ limit: '5mb' }));
|
|
||||||
app.use(passport.initialize());
|
|
||||||
|
|
||||||
configureGoogleStrategy();
|
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
|
||||||
res.json({ ok: true, service: 'ten99timecard-server' });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
|
||||||
app.use('/api/data', dataRoutes);
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { MongoClient, Db, Collection } from 'mongodb';
|
|
||||||
|
|
||||||
export interface UserDoc {
|
|
||||||
_id?: string;
|
|
||||||
email: string;
|
|
||||||
/** bcrypt hash — only for email-auth users */
|
|
||||||
passwordHash?: string;
|
|
||||||
/** Google profile id — only for oauth users */
|
|
||||||
googleId?: string;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The user's encrypted app-state blob. We NEVER see plaintext on the server —
|
|
||||||
* the client encrypts with the user's local password before PUT-ing.
|
|
||||||
*/
|
|
||||||
export interface BlobDoc {
|
|
||||||
_id?: string;
|
|
||||||
userId: string;
|
|
||||||
blob: string;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
let client: MongoClient | null = null;
|
|
||||||
let db: Db | null = null;
|
|
||||||
|
|
||||||
export async function connect(uri: string, dbName = 'ten99timecard'): Promise<Db> {
|
|
||||||
client = new MongoClient(uri);
|
|
||||||
await client.connect();
|
|
||||||
db = client.db(dbName);
|
|
||||||
await db.collection<UserDoc>('users').createIndex({ email: 1 }, { unique: true });
|
|
||||||
await db.collection<UserDoc>('users').createIndex({ googleId: 1 }, { sparse: true });
|
|
||||||
await db.collection<BlobDoc>('blobs').createIndex({ userId: 1 }, { unique: true });
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disconnect(): Promise<void> {
|
|
||||||
if (client) await client.close();
|
|
||||||
client = null;
|
|
||||||
db = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function users(): Collection<UserDoc> {
|
|
||||||
if (!db) throw new Error('DB not connected');
|
|
||||||
return db.collection<UserDoc>('users');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function blobs(): Collection<BlobDoc> {
|
|
||||||
if (!db) throw new Error('DB not connected');
|
|
||||||
return db.collection<BlobDoc>('blobs');
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import 'dotenv/config';
|
|
||||||
import { createApp } from './app.js';
|
|
||||||
import { connect } from './db/mongo.js';
|
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 4000;
|
|
||||||
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017';
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
await connect(MONGO_URI);
|
|
||||||
console.log('✓ MongoDB connected');
|
|
||||||
|
|
||||||
const app = createApp();
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`✓ ten99timecard-server listening on :${PORT}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error('Startup failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
|
||||||
const JWT_EXPIRY = '30d';
|
|
||||||
|
|
||||||
export interface AuthPayload {
|
|
||||||
userId: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function signToken(payload: AuthPayload): string {
|
|
||||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRY });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyToken(token: string): AuthPayload {
|
|
||||||
return jwt.verify(token, JWT_SECRET) as AuthPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
namespace Express {
|
|
||||||
interface Request {
|
|
||||||
auth?: AuthPayload;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
||||||
const header = req.headers.authorization;
|
|
||||||
if (!header?.startsWith('Bearer ')) {
|
|
||||||
return res.status(401).json({ error: 'Missing bearer token' });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
req.auth = verifyToken(header.slice(7));
|
|
||||||
next();
|
|
||||||
} catch {
|
|
||||||
res.status(401).json({ error: 'Invalid or expired token' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
import { Router } from 'express';
|
|
||||||
import bcrypt from 'bcrypt';
|
|
||||||
import passport from 'passport';
|
|
||||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { users } from '../db/mongo.js';
|
|
||||||
import { signToken } from '../middleware/auth.js';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
const BCRYPT_ROUNDS = 12;
|
|
||||||
|
|
||||||
const credsSchema = z.object({
|
|
||||||
email: z.string().email(),
|
|
||||||
password: z.string().min(8),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Email signup ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
router.post('/signup', async (req, res) => {
|
|
||||||
const parsed = credsSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: 'Invalid email or password too short (min 8)' });
|
|
||||||
}
|
|
||||||
const { email, password } = parsed.data;
|
|
||||||
|
|
||||||
const existing = await users().findOne({ email });
|
|
||||||
if (existing) {
|
|
||||||
return res.status(409).json({ error: 'Email already registered' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
||||||
const result = await users().insertOne({
|
|
||||||
email,
|
|
||||||
passwordHash,
|
|
||||||
createdAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const token = signToken({ userId: result.insertedId.toString(), email });
|
|
||||||
res.status(201).json({ token, email });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Email login ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
router.post('/login', async (req, res) => {
|
|
||||||
const parsed = credsSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
const { email, password } = parsed.data;
|
|
||||||
|
|
||||||
const user = await users().findOne({ email });
|
|
||||||
if (!user || !user.passwordHash) {
|
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
|
||||||
if (!ok) {
|
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = signToken({ userId: user._id!.toString(), email });
|
|
||||||
res.json({ token, email });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Google OAuth ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function configureGoogleStrategy() {
|
|
||||||
const clientID = process.env.GOOGLE_CLIENT_ID;
|
|
||||||
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
|
||||||
const callbackURL = process.env.GOOGLE_CALLBACK_URL || '/api/auth/google/callback';
|
|
||||||
|
|
||||||
if (!clientID || !clientSecret) {
|
|
||||||
console.warn('⚠ Google OAuth not configured (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET missing)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
passport.use(
|
|
||||||
new GoogleStrategy(
|
|
||||||
{ clientID, clientSecret, callbackURL },
|
|
||||||
async (_accessToken, _refreshToken, profile, done) => {
|
|
||||||
try {
|
|
||||||
const email = profile.emails?.[0]?.value;
|
|
||||||
if (!email) return done(new Error('No email from Google'));
|
|
||||||
|
|
||||||
let user = await users().findOne({ googleId: profile.id });
|
|
||||||
if (!user) {
|
|
||||||
// Try to link to existing email account
|
|
||||||
user = await users().findOne({ email });
|
|
||||||
if (user) {
|
|
||||||
await users().updateOne({ _id: user._id }, { $set: { googleId: profile.id } });
|
|
||||||
} else {
|
|
||||||
const result = await users().insertOne({
|
|
||||||
email,
|
|
||||||
googleId: profile.id,
|
|
||||||
createdAt: new Date(),
|
|
||||||
});
|
|
||||||
user = { _id: result.insertedId.toString(), email, googleId: profile.id, createdAt: new Date() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
done(null, { userId: user._id!.toString(), email });
|
|
||||||
} catch (err) {
|
|
||||||
done(err as Error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/google', passport.authenticate('google', { scope: ['email', 'profile'], session: false }));
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/google/callback',
|
|
||||||
passport.authenticate('google', { session: false, failureRedirect: '/?oauth=failed' }),
|
|
||||||
(req, res) => {
|
|
||||||
const payload = req.user as { userId: string; email: string };
|
|
||||||
const token = signToken(payload);
|
|
||||||
// Send token back to opener window via postMessage, then close popup
|
|
||||||
res.send(`
|
|
||||||
<!doctype html><html><body><script>
|
|
||||||
if (window.opener) {
|
|
||||||
window.opener.postMessage({
|
|
||||||
type: 't99-oauth',
|
|
||||||
token: ${JSON.stringify(token)},
|
|
||||||
email: ${JSON.stringify(payload.email)}
|
|
||||||
}, '*');
|
|
||||||
window.close();
|
|
||||||
} else {
|
|
||||||
document.body.innerText = 'Authenticated. You can close this window.';
|
|
||||||
}
|
|
||||||
</script></body></html>
|
|
||||||
`);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { Router } from 'express';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { blobs } from '../db/mongo.js';
|
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.use(requireAuth);
|
|
||||||
|
|
||||||
// GET /api/data — fetch encrypted blob
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
const doc = await blobs().findOne({ userId: req.auth!.userId });
|
|
||||||
if (!doc) return res.status(404).json({ error: 'No data' });
|
|
||||||
res.json({ blob: doc.blob, updatedAt: doc.updatedAt });
|
|
||||||
});
|
|
||||||
|
|
||||||
// PUT /api/data — store encrypted blob
|
|
||||||
const putSchema = z.object({ blob: z.string().min(1) });
|
|
||||||
|
|
||||||
router.put('/', async (req, res) => {
|
|
||||||
const parsed = putSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: 'Missing blob' });
|
|
||||||
}
|
|
||||||
await blobs().updateOne(
|
|
||||||
{ userId: req.auth!.userId },
|
|
||||||
{ $set: { blob: parsed.data.blob, updatedAt: new Date() } },
|
|
||||||
{ upsert: true },
|
|
||||||
);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE /api/data — remove blob
|
|
||||||
router.delete('/', async (req, res) => {
|
|
||||||
await blobs().deleteOne({ userId: req.auth!.userId });
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"resolveJsonModule": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
environment: 'node',
|
|
||||||
testTimeout: 30000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue