First version mostly built
This commit is contained in:
parent
27bb45f7df
commit
99a3dbd73c
42 changed files with 9443 additions and 3338 deletions
15
index.html
Normal file
15
index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ten99timecard — 1099 Income & Tax Tracker</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Serif+JP:wght@400;700&family=Orbitron:wght@400;700&family=Bangers&family=Comic+Neue:wght@400;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3673
package-lock.json
generated
3673
package-lock.json
generated
File diff suppressed because it is too large
Load diff
39
package.json
39
package.json
|
|
@ -2,18 +2,37 @@
|
|||
"name": "ten99timecard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Income logging, projections, and quarterly tax calculations for US 1099 workers",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run dev --workspace=client",
|
||||
"dev:server": "npm run dev --workspace=server",
|
||||
"build": "npm run build --workspace=client && npm run build --workspace=server",
|
||||
"test": "npm run test --workspace=client && npm run test --workspace=server",
|
||||
"test:client": "npm run test --workspace=client",
|
||||
"test:server": "npm run test --workspace=server"
|
||||
"dev": "node --max-http-header-size=81920 node_modules/.bin/vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"zustand": "^4.5.5",
|
||||
"recharts": "^2.12.7",
|
||||
"date-fns": "^3.6.0",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^2.1.4",
|
||||
"@vitest/coverage-v8": "^2.1.4",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"jsdom": "^25.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
99
src/App.tsx
Normal file
99
src/App.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom';
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
import { ThemeProvider } from '@/themes/ThemeProvider';
|
||||
import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { LedgerPage } from '@/pages/LedgerPage';
|
||||
import { TaxPage } from '@/pages/TaxPage';
|
||||
import { TimerPage } from '@/pages/TimerPage';
|
||||
import { SettingsPage } from '@/pages/SettingsPage';
|
||||
|
||||
export function App() {
|
||||
const saving = useAppStore((s) => s.saving);
|
||||
const saveError = useAppStore((s) => s.lastSaveError);
|
||||
const init = useAppStore((s) => s.init);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const closeMenu = () => setMenuOpen(false);
|
||||
const [kofiOpen, setKofiOpen] = useState(false);
|
||||
|
||||
useEffect(() => { init(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!kofiOpen) return;
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setKofiOpen(false); };
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [kofiOpen]);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<span className="logo">ten99timecard</span>
|
||||
<nav className={`app-nav${menuOpen ? ' open' : ''}`}>
|
||||
<NavLink to="/" end onClick={closeMenu}>Dashboard</NavLink>
|
||||
<NavLink to="/work" onClick={closeMenu}>Work</NavLink>
|
||||
<NavLink to="/payments" onClick={closeMenu}>Payments</NavLink>
|
||||
<NavLink to="/expenses" onClick={closeMenu}>Expenses</NavLink>
|
||||
<NavLink to="/tax" onClick={closeMenu}>Tax</NavLink>
|
||||
<NavLink to="/timer" onClick={closeMenu}>Timer</NavLink>
|
||||
<NavLink to="/settings" onClick={closeMenu}>Settings</NavLink>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2 text-sm header-status">
|
||||
{saving && <span className="text-muted">Saving…</span>}
|
||||
{saveError && <span className="text-danger" title={saveError}>⚠ Save failed</span>}
|
||||
</div>
|
||||
<button className="kofi-header-btn" onClick={() => setKofiOpen(true)}>
|
||||
☕ Donate
|
||||
</button>
|
||||
<button
|
||||
className="hamburger-btn"
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{menuOpen ? '✕' : '☰'}
|
||||
</button>
|
||||
</header>
|
||||
{menuOpen && <div className="nav-overlay" onClick={closeMenu} />}
|
||||
|
||||
{kofiOpen && (
|
||||
<div className="kofi-overlay" onClick={() => setKofiOpen(false)}>
|
||||
<div className="kofi-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="kofi-modal-header">
|
||||
<span>Support ten99timecard ☕</span>
|
||||
<button className="kofi-modal-close" onClick={() => setKofiOpen(false)} aria-label="Close">✕</button>
|
||||
</div>
|
||||
<iframe
|
||||
id="kofiframe"
|
||||
src="https://ko-fi.com/deltathiel/?hidefeed=true&widget=true&embed=true&preview=true"
|
||||
style={{ border: 'none', width: '100%', padding: '4px' }}
|
||||
height="712"
|
||||
title="ten99timecard"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<main className="app-body">
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/work" element={<LedgerPage initialTab="work" />} />
|
||||
<Route path="/payments" element={<LedgerPage initialTab="payments" />} />
|
||||
<Route path="/expenses" element={<LedgerPage initialTab="expenses" />} />
|
||||
<Route path="/tax" element={<TaxPage />} />
|
||||
<Route path="/timer" element={<TimerPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
<span>© {new Date().getFullYear()} Delta Thiel. All rights reserved.</span>
|
||||
<span className="app-footer-sep">·</span>
|
||||
<span>Not tax advice. For planning purposes only.</span>
|
||||
</footer>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
99
src/__tests__/appStore.test.ts
Normal file
99
src/__tests__/appStore.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
|
||||
describe('appStore — CRUD', () => {
|
||||
beforeEach(() => {
|
||||
useAppStore.setState({
|
||||
data: {
|
||||
workEntries: [],
|
||||
payments: [],
|
||||
expenses: [],
|
||||
taxInputs: {},
|
||||
dashboard: { charts: [], widgets: [] },
|
||||
settings: { theme: 'standard', mode: 'dark', defaultRate: 50 },
|
||||
version: 1,
|
||||
},
|
||||
vault: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('addWorkEntry assigns id and timestamps', () => {
|
||||
const e = useAppStore.getState().addWorkEntry({
|
||||
date: '2024-01-01', description: 'code review', amount: 150,
|
||||
});
|
||||
expect(e.id).toBeTruthy();
|
||||
expect(e.createdAt).toBeGreaterThan(0);
|
||||
expect(useAppStore.getState().data.workEntries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('updateWorkEntry patches fields', () => {
|
||||
const e = useAppStore.getState().addWorkEntry({
|
||||
date: '2024-01-01', description: 'old', amount: 100,
|
||||
});
|
||||
useAppStore.getState().updateWorkEntry(e.id, { description: 'new' });
|
||||
const updated = useAppStore.getState().data.workEntries[0];
|
||||
expect(updated.description).toBe('new');
|
||||
expect(updated.amount).toBe(100);
|
||||
expect(updated.updatedAt).toBeGreaterThanOrEqual(e.createdAt);
|
||||
});
|
||||
|
||||
it('deleteWorkEntry removes it', () => {
|
||||
const e = useAppStore.getState().addWorkEntry({
|
||||
date: '2024-01-01', description: 'x', amount: 1,
|
||||
});
|
||||
useAppStore.getState().deleteWorkEntry(e.id);
|
||||
expect(useAppStore.getState().data.workEntries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('addPayment / addExpense follow same pattern', () => {
|
||||
const p = useAppStore.getState().addPayment({
|
||||
date: '2024-01-01', amount: 5000, payer: 'Acme',
|
||||
});
|
||||
const ex = useAppStore.getState().addExpense({
|
||||
date: '2024-01-01', amount: 200, description: 'laptop', deductible: true,
|
||||
});
|
||||
expect(p.id).toBeTruthy();
|
||||
expect(ex.id).toBeTruthy();
|
||||
expect(useAppStore.getState().data.payments).toHaveLength(1);
|
||||
expect(useAppStore.getState().data.expenses).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('mutations bump version counter', () => {
|
||||
const v0 = useAppStore.getState().data.version;
|
||||
useAppStore.getState().addWorkEntry({ date: '2024-01-01', description: 'x' });
|
||||
expect(useAppStore.getState().data.version).toBe(v0 + 1);
|
||||
});
|
||||
|
||||
it('setTaxInputs merges per-year', () => {
|
||||
useAppStore.getState().setTaxInputs(2024, { priorYearAGI: 50000 });
|
||||
useAppStore.getState().setTaxInputs(2024, { priorYearTax: 6000 });
|
||||
const ti = useAppStore.getState().data.taxInputs[2024];
|
||||
expect(ti.priorYearAGI).toBe(50000);
|
||||
expect(ti.priorYearTax).toBe(6000);
|
||||
expect(ti.filingStatus).toBe('single');
|
||||
});
|
||||
|
||||
it('chart add/update/remove', () => {
|
||||
useAppStore.getState().addChart({ title: 'test chart' });
|
||||
const charts = useAppStore.getState().data.dashboard.charts;
|
||||
expect(charts).toHaveLength(1);
|
||||
const id = charts[0].id;
|
||||
|
||||
useAppStore.getState().updateChart(id, { type: 'bar' });
|
||||
expect(useAppStore.getState().data.dashboard.charts[0].type).toBe('bar');
|
||||
|
||||
useAppStore.getState().removeChart(id);
|
||||
expect(useAppStore.getState().data.dashboard.charts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('setTheme updates both theme and mode', () => {
|
||||
useAppStore.getState().setTheme('cyberpunk', 'light');
|
||||
expect(useAppStore.getState().data.settings.theme).toBe('cyberpunk');
|
||||
expect(useAppStore.getState().data.settings.mode).toBe('light');
|
||||
});
|
||||
|
||||
it('setDefaultRate updates settings', () => {
|
||||
useAppStore.getState().setDefaultRate(85);
|
||||
expect(useAppStore.getState().data.settings.defaultRate).toBe(85);
|
||||
});
|
||||
});
|
||||
239
src/__tests__/components.test.tsx
Normal file
239
src/__tests__/components.test.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { HierSpreadsheet } from '@/components/spreadsheet/HierSpreadsheet';
|
||||
import { WorkEntryForm, ExpenseForm } from '@/components/spreadsheet/EntryForm';
|
||||
import { Modal, ConfirmDialog } from '@/components/common/Modal';
|
||||
import type { HierNode } from '@/types';
|
||||
|
||||
// ─── HierSpreadsheet ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('HierSpreadsheet', () => {
|
||||
const tree: HierNode[] = [
|
||||
{
|
||||
key: '2024', level: 'year', label: '2024', value: 1500,
|
||||
children: [
|
||||
{
|
||||
key: '2024-03', level: 'month', label: 'March 2024', value: 1500,
|
||||
children: [
|
||||
{
|
||||
key: '2024-03-15', level: 'day', label: 'Mar 15', value: 1500,
|
||||
children: [
|
||||
{ key: 'i1', level: 'item', label: 'Task A', value: 1000, children: [] },
|
||||
{ key: 'i2', level: 'item', label: 'Task B', value: 500, children: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it('shows only top-level rows by default', () => {
|
||||
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
|
||||
expect(screen.getByText('2024')).toBeInTheDocument();
|
||||
expect(screen.queryByText('March 2024')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands row on click', async () => {
|
||||
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
|
||||
await userEvent.click(screen.getByText('2024'));
|
||||
expect(screen.getByText('March 2024')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses expanded row on second click', async () => {
|
||||
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
|
||||
await userEvent.click(screen.getByText('2024'));
|
||||
expect(screen.getByText('March 2024')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('2024'));
|
||||
expect(screen.queryByText('March 2024')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Item-level button expands entire tree', async () => {
|
||||
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Item' }));
|
||||
expect(screen.getByText('Task A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Task B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Year button collapses everything', async () => {
|
||||
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Item' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Year' }));
|
||||
expect(screen.queryByText('Task A')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Month button expands years but not days', async () => {
|
||||
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Month' }));
|
||||
expect(screen.getByText('March 2024')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mar 15')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays grand total', () => {
|
||||
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
|
||||
expect(screen.getByText('Grand Total')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('$1,500.00').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('calls onEdit for item rows', async () => {
|
||||
const onEdit = vi.fn();
|
||||
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" onEdit={onEdit} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Item' }));
|
||||
const editBtns = screen.getAllByTitle('Edit');
|
||||
await userEvent.click(editBtns[0]);
|
||||
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows empty state', () => {
|
||||
render(<HierSpreadsheet nodes={[]} valueLabel="Amount" />);
|
||||
expect(screen.getByText(/No entries yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WorkEntryForm ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('WorkEntryForm', () => {
|
||||
const numberInputs = (container: HTMLElement) =>
|
||||
Array.from(container.querySelectorAll('input[type="number"]')) as HTMLInputElement[];
|
||||
|
||||
it('submits flat amount', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(<WorkEntryForm defaultRate={50} onSubmit={onSubmit} onCancel={() => {}} />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/What did you work/), 'Code review');
|
||||
const [amountInput] = numberInputs(container);
|
||||
await userEvent.type(amountInput, '150');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: 'Code review',
|
||||
amount: 150,
|
||||
}));
|
||||
expect(onSubmit.mock.calls[0][0].hours).toBeUndefined();
|
||||
});
|
||||
|
||||
it('submits hours × rate', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(<WorkEntryForm defaultRate={75} onSubmit={onSubmit} onCancel={() => {}} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Time × rate/ }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/What did you work/), 'Dev work');
|
||||
const [hoursInput] = numberInputs(container);
|
||||
await userEvent.type(hoursInput, '4');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
hours: 4,
|
||||
rate: 75,
|
||||
}));
|
||||
expect(onSubmit.mock.calls[0][0].amount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows computed value preview for time mode', async () => {
|
||||
const { container } = render(<WorkEntryForm defaultRate={50} onSubmit={() => {}} onCancel={() => {}} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Time × rate/ }));
|
||||
const [hoursInput] = numberInputs(container);
|
||||
await userEvent.type(hoursInput, '3');
|
||||
expect(screen.getByText(/\$150\.00/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills from initial data', () => {
|
||||
render(
|
||||
<WorkEntryForm
|
||||
initial={{ date: '2024-03-15', description: 'existing', amount: 200 }}
|
||||
defaultRate={50}
|
||||
onSubmit={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByDisplayValue('existing')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('200')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ExpenseForm ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ExpenseForm', () => {
|
||||
it('includes deductible checkbox defaulting to true', () => {
|
||||
render(<ExpenseForm onSubmit={() => {}} onCancel={() => {}} />);
|
||||
const cb = screen.getByRole('checkbox');
|
||||
expect(cb).toBeChecked();
|
||||
});
|
||||
|
||||
it('submits with deductible toggled off', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(<ExpenseForm onSubmit={onSubmit} onCancel={() => {}} />);
|
||||
const textInputs = container.querySelectorAll('input:not([type="date"]):not([type="number"]):not([type="checkbox"])');
|
||||
const numInputs = container.querySelectorAll('input[type="number"]');
|
||||
await userEvent.type(textInputs[0] as HTMLInputElement, 'coffee');
|
||||
await userEvent.type(numInputs[0] as HTMLInputElement, '5');
|
||||
await userEvent.click(screen.getByRole('checkbox'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: 'coffee',
|
||||
amount: 5,
|
||||
deductible: false,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Modal ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Modal', () => {
|
||||
it('renders when open', () => {
|
||||
render(<Modal open title="Test" onClose={() => {}}>body</Modal>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
expect(screen.getByText('body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(<Modal open={false} title="Hidden" onClose={() => {}}>body</Modal>);
|
||||
expect(screen.queryByText('Hidden')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes on Escape key', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Modal open title="Test" onClose={onClose}>body</Modal>);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes on overlay click but not content click', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Modal open title="Test" onClose={onClose}>body</Modal>);
|
||||
fireEvent.click(screen.getByText('body'));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
it('calls onConfirm when confirmed', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Sure?"
|
||||
message="really?"
|
||||
confirmLabel="Yes"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Yes' }));
|
||||
expect(onConfirm).toHaveBeenCalled();
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onCancel when cancelled', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog open title="t" message="m" onConfirm={onConfirm} onCancel={onCancel} />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
53
src/__tests__/crypto.test.ts
Normal file
53
src/__tests__/crypto.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { encrypt, decrypt, verifyPassword } from '@/lib/crypto/encryption';
|
||||
|
||||
describe('encryption', () => {
|
||||
it('encrypts and decrypts round-trip', async () => {
|
||||
const plaintext = JSON.stringify({ hello: 'world', n: 42 });
|
||||
const ct = await encrypt(plaintext, 'my-password');
|
||||
expect(ct).not.toContain('hello');
|
||||
expect(ct).not.toBe(plaintext);
|
||||
const back = await decrypt(ct, 'my-password');
|
||||
expect(back).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('throws on wrong password', async () => {
|
||||
const ct = await encrypt('secret data', 'correct-password');
|
||||
await expect(decrypt(ct, 'wrong-password')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('produces different ciphertext each time (random salt+iv)', async () => {
|
||||
const ct1 = await encrypt('same plaintext', 'same-password');
|
||||
const ct2 = await encrypt('same plaintext', 'same-password');
|
||||
expect(ct1).not.toBe(ct2);
|
||||
// But both decrypt to the same thing
|
||||
expect(await decrypt(ct1, 'same-password')).toBe(await decrypt(ct2, 'same-password'));
|
||||
});
|
||||
|
||||
it('handles unicode content', async () => {
|
||||
const text = '日本語テスト 🎉 émojis';
|
||||
const ct = await encrypt(text, 'pw');
|
||||
expect(await decrypt(ct, 'pw')).toBe(text);
|
||||
});
|
||||
|
||||
it('handles large payloads', async () => {
|
||||
const big = 'x'.repeat(100_000);
|
||||
const ct = await encrypt(big, 'pw');
|
||||
expect(await decrypt(ct, 'pw')).toBe(big);
|
||||
});
|
||||
|
||||
it('verifyPassword returns true for correct password', async () => {
|
||||
const ct = await encrypt('data', 'correct');
|
||||
expect(await verifyPassword(ct, 'correct')).toBe(true);
|
||||
});
|
||||
|
||||
it('verifyPassword returns false for wrong password (no throw)', async () => {
|
||||
const ct = await encrypt('data', 'correct');
|
||||
expect(await verifyPassword(ct, 'wrong')).toBe(false);
|
||||
});
|
||||
|
||||
it('output is valid base64', async () => {
|
||||
const ct = await encrypt('test', 'pw');
|
||||
expect(() => atob(ct)).not.toThrow();
|
||||
});
|
||||
});
|
||||
54
src/__tests__/format.test.ts
Normal file
54
src/__tests__/format.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { fmtMoney, fmtDuration, fmtDurationVerbose, totalMinutes, msToHours } from '@/lib/format';
|
||||
|
||||
describe('fmtMoney', () => {
|
||||
it('formats dollars with 2 decimals', () => {
|
||||
expect(fmtMoney(1234.5)).toBe('$1,234.50');
|
||||
expect(fmtMoney(0)).toBe('$0.00');
|
||||
});
|
||||
|
||||
it('handles null/undefined', () => {
|
||||
expect(fmtMoney(null)).toBe('—');
|
||||
expect(fmtMoney(undefined)).toBe('—');
|
||||
});
|
||||
|
||||
it('handles negative values', () => {
|
||||
expect(fmtMoney(-500)).toContain('500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtDuration', () => {
|
||||
it('formats H:MM:SS', () => {
|
||||
expect(fmtDuration(0)).toBe('0:00:00');
|
||||
expect(fmtDuration(65_000)).toBe('0:01:05');
|
||||
expect(fmtDuration(3_661_000)).toBe('1:01:01'); // 1h 1m 1s
|
||||
expect(fmtDuration(10 * 3_600_000)).toBe('10:00:00');
|
||||
});
|
||||
|
||||
it('floors fractional seconds', () => {
|
||||
expect(fmtDuration(1_999)).toBe('0:00:01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtDurationVerbose', () => {
|
||||
it('formats Xh Xm Xs', () => {
|
||||
expect(fmtDurationVerbose(3_661_000)).toBe('1h 1m 1s');
|
||||
expect(fmtDurationVerbose(0)).toBe('0h 0m 0s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('totalMinutes', () => {
|
||||
it('computes h*60 + m ignoring seconds', () => {
|
||||
expect(totalMinutes(3_661_000)).toBe(61); // 1h 1m 1s → 61 min
|
||||
expect(totalMinutes(59_000)).toBe(0); // 59s → 0 min
|
||||
expect(totalMinutes(90 * 60_000)).toBe(90); // 90 min
|
||||
});
|
||||
});
|
||||
|
||||
describe('msToHours', () => {
|
||||
it('converts milliseconds to decimal hours', () => {
|
||||
expect(msToHours(3_600_000)).toBe(1);
|
||||
expect(msToHours(1_800_000)).toBe(0.5);
|
||||
expect(msToHours(5_400_000)).toBe(1.5);
|
||||
});
|
||||
});
|
||||
50
src/__tests__/setup.ts
Normal file
50
src/__tests__/setup.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import '@testing-library/jest-dom';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { beforeEach } from 'vitest';
|
||||
|
||||
// jsdom lacks WebCrypto subtle — polyfill from Node
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
Object.defineProperty(globalThis, 'crypto', { value: webcrypto });
|
||||
}
|
||||
|
||||
// jsdom lacks ResizeObserver (needed by Recharts)
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;
|
||||
|
||||
// structuredClone polyfill for older jsdom
|
||||
if (!globalThis.structuredClone) {
|
||||
globalThis.structuredClone = (o: unknown) => JSON.parse(JSON.stringify(o));
|
||||
}
|
||||
|
||||
// Silence "not implemented: navigation" warnings from jsdom
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('Not implemented')) return;
|
||||
originalWarn(...args);
|
||||
};
|
||||
|
||||
// jsdom 25.x backs localStorage with a file store that lacks clear().
|
||||
// Replace it with a plain in-memory implementation for tests.
|
||||
const store: Record<string, string> = {};
|
||||
const localStorageMock: Storage = {
|
||||
getItem: (k) => store[k] ?? null,
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
removeItem: (k) => { delete store[k]; },
|
||||
clear: () => { Object.keys(store).forEach((k) => delete store[k]); },
|
||||
get length() { return Object.keys(store).length; },
|
||||
key: (i) => Object.keys(store)[i] ?? null,
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
|
||||
|
||||
// Reset cookies and localStorage between tests
|
||||
beforeEach(() => {
|
||||
document.cookie.split(';').forEach((c) => {
|
||||
const name = c.split('=')[0].trim();
|
||||
if (name) document.cookie = `${name}=; max-age=0; path=/`;
|
||||
});
|
||||
localStorage.clear();
|
||||
});
|
||||
256
src/__tests__/stats.test.ts
Normal file
256
src/__tests__/stats.test.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { aggregate, buildHierarchy, buildChartSeries } from '@/lib/stats/aggregate';
|
||||
import { workEntryValue } from '@/types';
|
||||
import type { WorkEntry, Payment, Expense } from '@/types';
|
||||
|
||||
const mkWork = (date: string, amount?: number, hours?: number, rate?: number): WorkEntry => ({
|
||||
id: `w-${date}-${amount ?? hours}`, date,
|
||||
description: 'work', amount, hours, rate,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
});
|
||||
|
||||
const mkPayment = (date: string, amount: number): Payment => ({
|
||||
id: `p-${date}-${amount}`, date, amount, payer: 'X', createdAt: 0, updatedAt: 0,
|
||||
});
|
||||
|
||||
const mkExpense = (date: string, amount: number, deductible = true): Expense => ({
|
||||
id: `e-${date}-${amount}`, date, amount, description: 'exp', deductible,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
});
|
||||
|
||||
describe('workEntryValue', () => {
|
||||
it('returns amount when set', () => {
|
||||
expect(workEntryValue(mkWork('2024-01-01', 150))).toBe(150);
|
||||
});
|
||||
|
||||
it('returns hours * rate when amount not set', () => {
|
||||
expect(workEntryValue(mkWork('2024-01-01', undefined, 4, 50))).toBe(200);
|
||||
});
|
||||
|
||||
it('amount takes precedence over hours/rate', () => {
|
||||
const e = mkWork('2024-01-01', 100, 10, 50);
|
||||
expect(workEntryValue(e)).toBe(100);
|
||||
});
|
||||
|
||||
it('returns 0 when neither set', () => {
|
||||
expect(workEntryValue(mkWork('2024-01-01'))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregate', () => {
|
||||
it('rolls up days → months → years', () => {
|
||||
const agg = aggregate(
|
||||
[],
|
||||
[mkPayment('2024-01-15', 1000), mkPayment('2024-01-20', 500), mkPayment('2024-02-10', 300)],
|
||||
[],
|
||||
);
|
||||
expect(agg.days.get('2024-01-15')?.payments).toBe(1000);
|
||||
expect(agg.months.get('2024-01')?.payments).toBe(1500);
|
||||
expect(agg.months.get('2024-02')?.payments).toBe(300);
|
||||
expect(agg.years.find((y) => y.label === '2024')?.payments).toBe(1800);
|
||||
});
|
||||
|
||||
it('computes net = payments − expenses', () => {
|
||||
const agg = aggregate(
|
||||
[],
|
||||
[mkPayment('2024-03-01', 5000)],
|
||||
[mkExpense('2024-03-01', 1200)],
|
||||
);
|
||||
expect(agg.days.get('2024-03-01')?.net).toBe(3800);
|
||||
expect(agg.months.get('2024-03')?.net).toBe(3800);
|
||||
});
|
||||
|
||||
it('separates deductible from total expenses', () => {
|
||||
const agg = aggregate(
|
||||
[],
|
||||
[],
|
||||
[mkExpense('2024-03-01', 100, true), mkExpense('2024-03-01', 50, false)],
|
||||
);
|
||||
const d = agg.days.get('2024-03-01')!;
|
||||
expect(d.expenses).toBe(150);
|
||||
expect(d.deductibleExpenses).toBe(100);
|
||||
});
|
||||
|
||||
it('computes monthly average per active day', () => {
|
||||
const agg = aggregate(
|
||||
[],
|
||||
[mkPayment('2024-03-01', 100), mkPayment('2024-03-10', 200)],
|
||||
[],
|
||||
);
|
||||
// 2 active days, 300 total → 150 avg
|
||||
expect(agg.months.get('2024-03')?.avgPerChild).toBe(150);
|
||||
expect(agg.months.get('2024-03')?.childCount).toBe(2);
|
||||
});
|
||||
|
||||
it('computes yearly average per active month', () => {
|
||||
const agg = aggregate(
|
||||
[],
|
||||
[mkPayment('2024-01-05', 1000), mkPayment('2024-03-05', 2000)],
|
||||
[],
|
||||
);
|
||||
const y = agg.years.find((y) => y.label === '2024')!;
|
||||
// 2 months active, 3000 net → 1500 avg/month
|
||||
expect(y.childCount).toBe(2);
|
||||
expect(y.avgPerChild).toBe(1500);
|
||||
});
|
||||
|
||||
it('projects year-end from YTD (halfway through year)', () => {
|
||||
const agg = aggregate(
|
||||
[],
|
||||
[mkPayment('2024-01-01', 50000)],
|
||||
[],
|
||||
new Date('2024-07-01'), // ~halfway
|
||||
);
|
||||
const y = agg.years.find((y) => y.label === '2024')!;
|
||||
expect(y.projected).toBeGreaterThan(90000);
|
||||
expect(y.projected).toBeLessThan(110000);
|
||||
});
|
||||
|
||||
it('does not project completed years', () => {
|
||||
const agg = aggregate([], [mkPayment('2023-06-01', 1000)], [], new Date('2024-06-01'));
|
||||
const y = agg.years.find((y) => y.label === '2023')!;
|
||||
expect(y.projected).toBeNull();
|
||||
});
|
||||
|
||||
it('projects month-end from current day', () => {
|
||||
const agg = aggregate(
|
||||
[],
|
||||
[mkPayment('2024-06-10', 1000)],
|
||||
[],
|
||||
new Date('2024-06-15'), // halfway through June
|
||||
);
|
||||
const m = agg.months.get('2024-06')!;
|
||||
expect(m.projected).toBeGreaterThan(1500);
|
||||
expect(m.projected).toBeLessThan(2500);
|
||||
});
|
||||
|
||||
it('sorts years descending', () => {
|
||||
const agg = aggregate(
|
||||
[],
|
||||
[mkPayment('2022-01-01', 1), mkPayment('2024-01-01', 1), mkPayment('2023-01-01', 1)],
|
||||
[],
|
||||
);
|
||||
expect(agg.years.map((y) => y.label)).toEqual(['2024', '2023', '2022']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHierarchy', () => {
|
||||
it('builds year→month→day→item tree', () => {
|
||||
const work = [
|
||||
mkWork('2024-03-15', 100),
|
||||
mkWork('2024-03-15', 50),
|
||||
mkWork('2024-04-01', 200),
|
||||
];
|
||||
const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), (e) => (e as WorkEntry).description);
|
||||
expect(tree).toHaveLength(1); // one year
|
||||
expect(tree[0].level).toBe('year');
|
||||
expect(tree[0].value).toBe(350);
|
||||
expect(tree[0].children).toHaveLength(2); // two months
|
||||
const march = tree[0].children.find((m) => m.key === '2024-03')!;
|
||||
expect(march.value).toBe(150);
|
||||
expect(march.children).toHaveLength(1); // one day
|
||||
expect(march.children[0].children).toHaveLength(2); // two items
|
||||
});
|
||||
|
||||
it('attaches entry to item leaves', () => {
|
||||
const work = [mkWork('2024-01-01', 100)];
|
||||
const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), () => 'label');
|
||||
const item = tree[0].children[0].children[0].children[0];
|
||||
expect(item.level).toBe('item');
|
||||
expect(item.entry).toBe(work[0]);
|
||||
});
|
||||
|
||||
it('sorts years, months, days descending (newest first)', () => {
|
||||
const work = [mkWork('2023-01-01', 1), mkWork('2024-01-01', 1)];
|
||||
const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), () => '');
|
||||
expect(tree[0].key).toBe('2024');
|
||||
expect(tree[1].key).toBe('2023');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildChartSeries', () => {
|
||||
it('produces points at requested granularity', () => {
|
||||
const series = buildChartSeries(
|
||||
[],
|
||||
[mkPayment('2024-01-15', 100), mkPayment('2024-02-10', 200)],
|
||||
[],
|
||||
['payments'],
|
||||
'month',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
expect(series).toHaveLength(2);
|
||||
expect(series[0].payments).toBe(100);
|
||||
expect(series[1].payments).toBe(200);
|
||||
});
|
||||
|
||||
it('filters by date range', () => {
|
||||
const series = buildChartSeries(
|
||||
[],
|
||||
[mkPayment('2024-01-01', 1), mkPayment('2024-06-01', 1), mkPayment('2024-12-01', 1)],
|
||||
[],
|
||||
['payments'],
|
||||
'month',
|
||||
'2024-03',
|
||||
'2024-09',
|
||||
);
|
||||
expect(series).toHaveLength(1);
|
||||
expect(series[0].label).toBe('2024-06');
|
||||
});
|
||||
|
||||
it('computes cumulative metrics', () => {
|
||||
const series = buildChartSeries(
|
||||
[],
|
||||
[mkPayment('2024-01-01', 100), mkPayment('2024-02-01', 200), mkPayment('2024-03-01', 300)],
|
||||
[],
|
||||
['cumulativePayments'],
|
||||
'month',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
expect(series[0].cumulativePayments).toBe(100);
|
||||
expect(series[1].cumulativePayments).toBe(300);
|
||||
expect(series[2].cumulativePayments).toBe(600);
|
||||
});
|
||||
|
||||
it('sorts ascending for time-series display', () => {
|
||||
const series = buildChartSeries(
|
||||
[],
|
||||
[mkPayment('2024-03-01', 1), mkPayment('2024-01-01', 1)],
|
||||
[],
|
||||
['payments'],
|
||||
'month',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
expect(series[0].label < series[1].label).toBe(true);
|
||||
});
|
||||
|
||||
it('groups by week when requested', () => {
|
||||
const series = buildChartSeries(
|
||||
[],
|
||||
// Monday and Tuesday of same week
|
||||
[mkPayment('2024-06-03', 100), mkPayment('2024-06-04', 50)],
|
||||
[],
|
||||
['payments'],
|
||||
'week',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
expect(series).toHaveLength(1);
|
||||
expect(series[0].payments).toBe(150);
|
||||
});
|
||||
|
||||
it('computes netIncome metric', () => {
|
||||
const series = buildChartSeries(
|
||||
[],
|
||||
[mkPayment('2024-01-01', 1000)],
|
||||
[mkExpense('2024-01-01', 300)],
|
||||
['netIncome'],
|
||||
'month',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
expect(series[0].netIncome).toBe(700);
|
||||
});
|
||||
});
|
||||
100
src/__tests__/storage.test.ts
Normal file
100
src/__tests__/storage.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CookieStorage } from '@/lib/storage/adapters';
|
||||
import { Vault } from '@/lib/storage/vault';
|
||||
import type { AppData } from '@/types';
|
||||
|
||||
describe('CookieStorage', () => {
|
||||
it('saves and loads a small blob', async () => {
|
||||
const cs = new CookieStorage('testuser');
|
||||
await cs.save('hello-world-encrypted-blob');
|
||||
const loaded = await cs.load();
|
||||
expect(loaded).toBe('hello-world-encrypted-blob');
|
||||
});
|
||||
|
||||
it('chunks large blobs across multiple cookies', async () => {
|
||||
const cs = new CookieStorage('big');
|
||||
const big = 'A'.repeat(10000); // > chunk size
|
||||
await cs.save(big);
|
||||
const loaded = await cs.load();
|
||||
expect(loaded).toBe(big);
|
||||
});
|
||||
|
||||
it('isolates namespaces', async () => {
|
||||
const alice = new CookieStorage('alice');
|
||||
const bob = new CookieStorage('bob');
|
||||
await alice.save('alice-data');
|
||||
await bob.save('bob-data');
|
||||
expect(await alice.load()).toBe('alice-data');
|
||||
expect(await bob.load()).toBe('bob-data');
|
||||
});
|
||||
|
||||
it('returns null when nothing stored', async () => {
|
||||
const cs = new CookieStorage('empty');
|
||||
expect(await cs.load()).toBeNull();
|
||||
});
|
||||
|
||||
it('clear removes all chunks', async () => {
|
||||
const cs = new CookieStorage('clearme');
|
||||
await cs.save('some-data-here');
|
||||
await cs.clear();
|
||||
expect(await cs.load()).toBeNull();
|
||||
});
|
||||
|
||||
it('overwrite clears old chunks (shrinking data)', async () => {
|
||||
const cs = new CookieStorage('shrink');
|
||||
await cs.save('X'.repeat(10000)); // many chunks
|
||||
await cs.save('small'); // one chunk
|
||||
expect(await cs.load()).toBe('small');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vault', () => {
|
||||
const mkData = (): AppData => ({
|
||||
workEntries: [{
|
||||
id: '1', date: '2024-01-01', description: 'test', amount: 100,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
}],
|
||||
payments: [],
|
||||
expenses: [],
|
||||
taxInputs: {},
|
||||
dashboard: { charts: [], widgets: [] },
|
||||
settings: { theme: 'standard', mode: 'dark', storageMode: 'cookie', defaultRate: 50 },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
it('encrypts on save and decrypts on load (cookie mode)', async () => {
|
||||
const v = new Vault({ mode: 'cookie', username: 'u1', password: 'supersecret' });
|
||||
const data = mkData();
|
||||
await v.save(data);
|
||||
|
||||
// Verify cookie content is NOT plaintext
|
||||
expect(document.cookie).not.toContain('test');
|
||||
expect(document.cookie).not.toContain('workEntries');
|
||||
|
||||
const loaded = await v.load();
|
||||
expect(loaded).toEqual(data);
|
||||
});
|
||||
|
||||
it('wrong password fails to load', async () => {
|
||||
const v1 = new Vault({ mode: 'cookie', username: 'u2', password: 'correct' });
|
||||
await v1.save(mkData());
|
||||
|
||||
const v2 = new Vault({ mode: 'cookie', username: 'u2', password: 'wrong' });
|
||||
await expect(v2.load()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('different users see different data', async () => {
|
||||
const va = new Vault({ mode: 'cookie', username: 'alice', password: 'pw' });
|
||||
const vb = new Vault({ mode: 'cookie', username: 'bob', password: 'pw' });
|
||||
const dataA = mkData();
|
||||
dataA.workEntries[0].description = 'alice-work';
|
||||
const dataB = mkData();
|
||||
dataB.workEntries[0].description = 'bob-work';
|
||||
|
||||
await va.save(dataA);
|
||||
await vb.save(dataB);
|
||||
|
||||
expect((await va.load())?.workEntries[0].description).toBe('alice-work');
|
||||
expect((await vb.load())?.workEntries[0].description).toBe('bob-work');
|
||||
});
|
||||
});
|
||||
282
src/__tests__/tax.test.ts
Normal file
282
src/__tests__/tax.test.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateTax } from '@/lib/tax/calculate';
|
||||
import { applyBrackets, getTaxYearData } from '@/lib/tax/brackets';
|
||||
import type { Payment, Expense, TaxInputs } from '@/types';
|
||||
|
||||
const mkPayment = (date: string, amount: number): Payment => ({
|
||||
id: 'p', date, amount, payer: 'Client', createdAt: 0, updatedAt: 0,
|
||||
});
|
||||
|
||||
const mkExpense = (date: string, amount: number, deductible = true): Expense => ({
|
||||
id: 'e', date, amount, description: 'exp', deductible, createdAt: 0, updatedAt: 0,
|
||||
});
|
||||
|
||||
describe('applyBrackets', () => {
|
||||
it('returns 0 for zero/negative income', () => {
|
||||
const b = getTaxYearData(2024).brackets.single;
|
||||
expect(applyBrackets(b, 0)).toBe(0);
|
||||
expect(applyBrackets(b, -100)).toBe(0);
|
||||
});
|
||||
|
||||
it('applies 10% bracket correctly (2024 single)', () => {
|
||||
const b = getTaxYearData(2024).brackets.single;
|
||||
expect(applyBrackets(b, 10000)).toBeCloseTo(1000, 2);
|
||||
});
|
||||
|
||||
it('applies across multiple brackets (2024 single, $50k taxable)', () => {
|
||||
const b = getTaxYearData(2024).brackets.single;
|
||||
// 11600 * 0.10 + (47150 - 11600) * 0.12 + (50000 - 47150) * 0.22
|
||||
const expected = 11600 * 0.10 + 35550 * 0.12 + 2850 * 0.22;
|
||||
expect(applyBrackets(b, 50000)).toBeCloseTo(expected, 2);
|
||||
});
|
||||
|
||||
it('handles top bracket (infinity)', () => {
|
||||
const b = getTaxYearData(2024).brackets.single;
|
||||
const tax = applyBrackets(b, 1_000_000);
|
||||
expect(tax).toBeGreaterThan(300_000);
|
||||
expect(Number.isFinite(tax)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTaxYearData', () => {
|
||||
it('returns exact year data when available', () => {
|
||||
expect(getTaxYearData(2024).year).toBe(2024);
|
||||
expect(getTaxYearData(2025).year).toBe(2025);
|
||||
});
|
||||
|
||||
it('falls back to closest year for unknown years', () => {
|
||||
expect(getTaxYearData(2030).year).toBe(2025);
|
||||
expect(getTaxYearData(2020).year).toBe(2024);
|
||||
});
|
||||
|
||||
it('provides 4 quarterly due dates', () => {
|
||||
expect(getTaxYearData(2024).quarterlyDueDates).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTax — core SE tax', () => {
|
||||
const baseInputs: TaxInputs = { taxYear: 2024, filingStatus: 'single' };
|
||||
|
||||
it('zero income → zero tax', () => {
|
||||
const r = calculateTax([], [], baseInputs);
|
||||
expect(r.totalFederalTax).toBe(0);
|
||||
expect(r.netProfit).toBe(0);
|
||||
expect(r.totalSETax).toBe(0);
|
||||
});
|
||||
|
||||
it('$50k net profit, single, 2024 — SE tax math', () => {
|
||||
const r = calculateTax([mkPayment('2024-03-01', 50000)], [], baseInputs);
|
||||
// SE base: 50000 * 0.9235 = 46175
|
||||
expect(r.seTaxableBase).toBeCloseTo(46175, 2);
|
||||
// SS: 46175 * 0.124 = 5725.70
|
||||
expect(r.socialSecurityTax).toBeCloseTo(5725.70, 2);
|
||||
// Medicare: 46175 * 0.029 = 1339.08 (rounds to 1339.08)
|
||||
expect(r.medicareTax).toBeCloseTo(1339.08, 1);
|
||||
// Total SE: ~7064.78
|
||||
expect(r.totalSETax).toBeCloseTo(7064.78, 1);
|
||||
// SE deduction = half of (SS + Medicare)
|
||||
expect(r.seTaxDeduction).toBeCloseTo(3532.39, 1);
|
||||
});
|
||||
|
||||
it('deductible expenses reduce net profit', () => {
|
||||
const r = calculateTax(
|
||||
[mkPayment('2024-03-01', 50000)],
|
||||
[mkExpense('2024-02-01', 10000)],
|
||||
baseInputs,
|
||||
);
|
||||
expect(r.netProfit).toBe(40000);
|
||||
});
|
||||
|
||||
it('non-deductible expenses do NOT reduce net profit', () => {
|
||||
const r = calculateTax(
|
||||
[mkPayment('2024-03-01', 50000)],
|
||||
[mkExpense('2024-02-01', 10000, false)],
|
||||
baseInputs,
|
||||
);
|
||||
expect(r.netProfit).toBe(50000);
|
||||
});
|
||||
|
||||
it('filters payments by tax year', () => {
|
||||
const r = calculateTax(
|
||||
[mkPayment('2023-12-31', 99999), mkPayment('2024-01-01', 1000)],
|
||||
[],
|
||||
baseInputs,
|
||||
);
|
||||
expect(r.grossReceipts).toBe(1000);
|
||||
});
|
||||
|
||||
it('Social Security tax capped at wage base', () => {
|
||||
const r = calculateTax([mkPayment('2024-01-01', 500000)], [], baseInputs);
|
||||
// 500k * 0.9235 = 461750, but SS only applies to first 168600
|
||||
expect(r.socialSecurityTax).toBeCloseTo(168600 * 0.124, 2);
|
||||
// Medicare has no cap
|
||||
expect(r.medicareTax).toBeCloseTo(461750 * 0.029, 1);
|
||||
});
|
||||
|
||||
it('W-2 wages reduce SS room (combined cap)', () => {
|
||||
const r = calculateTax(
|
||||
[mkPayment('2024-01-01', 100000)],
|
||||
[],
|
||||
{ ...baseInputs, w2Wages: 168600 }, // already hit SS cap via W-2
|
||||
);
|
||||
expect(r.socialSecurityTax).toBe(0);
|
||||
// Medicare still applies
|
||||
expect(r.medicareTax).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('Additional Medicare kicks in above threshold', () => {
|
||||
const low = calculateTax([mkPayment('2024-01-01', 100000)], [], baseInputs);
|
||||
expect(low.additionalMedicareTax).toBe(0);
|
||||
|
||||
const high = calculateTax([mkPayment('2024-01-01', 300000)], [], baseInputs);
|
||||
expect(high.additionalMedicareTax).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('Standard deduction applied (2024 single = $14,600)', () => {
|
||||
const r = calculateTax([mkPayment('2024-01-01', 30000)], [], baseInputs);
|
||||
expect(r.standardDeduction).toBe(14600);
|
||||
});
|
||||
|
||||
it('QBI deduction reduces taxable income', () => {
|
||||
const r = calculateTax([mkPayment('2024-01-01', 80000)], [], baseInputs);
|
||||
expect(r.qbiDeduction).toBeGreaterThan(0);
|
||||
// QBI capped at 20% of QBI
|
||||
const qbiBase = r.netProfit - r.seTaxDeduction;
|
||||
expect(r.qbiDeduction).toBeLessThanOrEqual(qbiBase * 0.20 + 0.01);
|
||||
});
|
||||
|
||||
it('QBI phases out for high earners', () => {
|
||||
// Way above phaseout end
|
||||
const r = calculateTax([mkPayment('2024-01-01', 500000)], [], baseInputs);
|
||||
expect(r.qbiDeduction).toBe(0);
|
||||
});
|
||||
|
||||
it('Married Filing Jointly uses MFJ brackets & deduction', () => {
|
||||
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], {
|
||||
taxYear: 2024,
|
||||
filingStatus: 'mfj',
|
||||
});
|
||||
expect(r.standardDeduction).toBe(29200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTax — safe harbor & quarterly', () => {
|
||||
const baseInputs: TaxInputs = { taxYear: 2024, filingStatus: 'single' };
|
||||
|
||||
it('prompts for prior year data when missing', () => {
|
||||
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], baseInputs);
|
||||
const fields = r.prompts.map((p) => p.field);
|
||||
expect(fields).toContain('priorYearTax');
|
||||
expect(fields).toContain('priorYearAGI');
|
||||
});
|
||||
|
||||
it('no safe harbor when prior year data missing', () => {
|
||||
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], baseInputs);
|
||||
expect(r.safeHarborAmount).toBeNull();
|
||||
expect(r.safeHarborMet).toBeNull();
|
||||
});
|
||||
|
||||
it('computes 100% safe harbor for normal AGI', () => {
|
||||
const r = calculateTax([mkPayment('2024-01-01', 100000)], [], {
|
||||
...baseInputs,
|
||||
priorYearTax: 8000,
|
||||
priorYearAGI: 60000,
|
||||
});
|
||||
// Safe harbor = min(100% of prior tax, 90% of current)
|
||||
// Prior: 8000 * 1.00 = 8000
|
||||
// Current 90% will be higher, so safe harbor = 8000
|
||||
expect(r.safeHarborAmount).toBe(8000);
|
||||
});
|
||||
|
||||
it('computes 110% safe harbor for high prior AGI', () => {
|
||||
const r = calculateTax([mkPayment('2024-01-01', 200000)], [], {
|
||||
...baseInputs,
|
||||
priorYearTax: 10000,
|
||||
priorYearAGI: 200000, // > 150k
|
||||
});
|
||||
// 110% rule: 10000 * 1.10 = 11000
|
||||
expect(r.safeHarborAmount).toBeLessThanOrEqual(11000);
|
||||
expect(r.notes.some((n) => n.includes('110%'))).toBe(true);
|
||||
});
|
||||
|
||||
it('safeHarborMet true when already paid enough', () => {
|
||||
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], {
|
||||
...baseInputs,
|
||||
priorYearTax: 5000,
|
||||
priorYearAGI: 40000,
|
||||
estimatedPaymentsMade: 5000,
|
||||
});
|
||||
expect(r.safeHarborMet).toBe(true);
|
||||
});
|
||||
|
||||
it('safeHarborMet false when underpaid', () => {
|
||||
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], {
|
||||
...baseInputs,
|
||||
priorYearTax: 5000,
|
||||
priorYearAGI: 40000,
|
||||
estimatedPaymentsMade: 100,
|
||||
});
|
||||
expect(r.safeHarborMet).toBe(false);
|
||||
});
|
||||
|
||||
it('produces 4 quarterly payments summing to annual liability', () => {
|
||||
const r = calculateTax([mkPayment('2024-01-01', 100000)], [], baseInputs);
|
||||
expect(r.quarterlySchedule).toHaveLength(4);
|
||||
const sum = r.quarterlySchedule.reduce((s, q) => s + q.projectedAmount, 0);
|
||||
expect(sum).toBeCloseTo(r.totalFederalTax, 0);
|
||||
});
|
||||
|
||||
it('marks past-due quarters correctly', () => {
|
||||
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], baseInputs, {
|
||||
asOf: new Date('2024-07-01'),
|
||||
});
|
||||
// Q1 (Apr 15) and Q2 (Jun 17) should be past due
|
||||
expect(r.quarterlySchedule[0].isPastDue).toBe(true);
|
||||
expect(r.quarterlySchedule[1].isPastDue).toBe(true);
|
||||
expect(r.quarterlySchedule[2].isPastDue).toBe(false);
|
||||
expect(r.quarterlySchedule[3].isPastDue).toBe(false);
|
||||
});
|
||||
|
||||
it('withholding reduces quarterly need', () => {
|
||||
const withW2 = calculateTax([mkPayment('2024-01-01', 50000)], [], {
|
||||
...baseInputs,
|
||||
federalWithholding: 5000,
|
||||
});
|
||||
const without = calculateTax([mkPayment('2024-01-01', 50000)], [], baseInputs);
|
||||
expect(withW2.quarterlySchedule[0].projectedAmount).toBeLessThan(
|
||||
without.quarterlySchedule[0].projectedAmount,
|
||||
);
|
||||
});
|
||||
|
||||
it('notes <$1000 threshold exemption', () => {
|
||||
const r = calculateTax([mkPayment('2024-01-01', 3000)], [], baseInputs);
|
||||
expect(r.notes.some((n) => n.includes('$1,000'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTax — projection mode', () => {
|
||||
it('scales YTD to full year when project=true', () => {
|
||||
// Halfway through 2024 with $50k → project $100k
|
||||
const actual = calculateTax([mkPayment('2024-01-15', 50000)], [], {
|
||||
taxYear: 2024,
|
||||
filingStatus: 'single',
|
||||
}, {
|
||||
asOf: new Date('2024-07-01'),
|
||||
project: true,
|
||||
});
|
||||
expect(actual.grossReceipts).toBeGreaterThan(90000);
|
||||
expect(actual.grossReceipts).toBeLessThan(110000);
|
||||
expect(actual.notes.some((n) => n.includes('Projected'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not project past years', () => {
|
||||
const r = calculateTax([mkPayment('2024-06-01', 50000)], [], {
|
||||
taxYear: 2024,
|
||||
filingStatus: 'single',
|
||||
}, {
|
||||
asOf: new Date('2025-06-01'),
|
||||
project: true,
|
||||
});
|
||||
expect(r.grossReceipts).toBe(50000);
|
||||
});
|
||||
});
|
||||
245
src/__tests__/timerStore.test.ts
Normal file
245
src/__tests__/timerStore.test.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { useTimerStore } from '@/store/timerStore';
|
||||
|
||||
describe('timerStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useTimerStore.setState({
|
||||
currentRate: 50,
|
||||
running: false,
|
||||
runStartedAt: null,
|
||||
accumulatedMs: 0,
|
||||
splits: [],
|
||||
lastHeartbeat: Date.now(),
|
||||
elapsedMs: 0,
|
||||
crashRecovery: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('starts and tracks elapsed time', () => {
|
||||
const s = useTimerStore.getState();
|
||||
s.start();
|
||||
expect(useTimerStore.getState().running).toBe(true);
|
||||
vi.advanceTimersByTime(5000);
|
||||
useTimerStore.getState()._tick();
|
||||
expect(useTimerStore.getState().elapsedMs).toBeGreaterThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it('pause freezes elapsed and stops ticking', () => {
|
||||
useTimerStore.getState().start();
|
||||
vi.advanceTimersByTime(3000);
|
||||
useTimerStore.getState().pause();
|
||||
const frozen = useTimerStore.getState().elapsedMs;
|
||||
expect(useTimerStore.getState().running).toBe(false);
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(useTimerStore.getState().elapsedMs).toBe(frozen);
|
||||
});
|
||||
|
||||
it('resume after pause accumulates correctly', () => {
|
||||
useTimerStore.getState().start();
|
||||
vi.advanceTimersByTime(2000);
|
||||
useTimerStore.getState().pause();
|
||||
vi.advanceTimersByTime(5000); // paused time doesn't count
|
||||
useTimerStore.getState().start();
|
||||
vi.advanceTimersByTime(3000);
|
||||
useTimerStore.getState()._tick();
|
||||
const elapsed = useTimerStore.getState().elapsedMs;
|
||||
expect(elapsed).toBeGreaterThanOrEqual(5000);
|
||||
expect(elapsed).toBeLessThan(7000); // not 10000
|
||||
});
|
||||
|
||||
it('split records current segment and resets clock', () => {
|
||||
useTimerStore.getState().start();
|
||||
vi.advanceTimersByTime(4000);
|
||||
const split = useTimerStore.getState().split('task A');
|
||||
|
||||
expect(split.elapsedMs).toBeGreaterThanOrEqual(4000);
|
||||
expect(split.rate).toBe(50);
|
||||
expect(split.label).toBe('task A');
|
||||
expect(split.recorded).toBe(false);
|
||||
expect(useTimerStore.getState().splits).toHaveLength(1);
|
||||
// Clock resets but keeps running
|
||||
expect(useTimerStore.getState().accumulatedMs).toBe(0);
|
||||
expect(useTimerStore.getState().running).toBe(true);
|
||||
});
|
||||
|
||||
it('split preserves rate at time of split (rate change after doesn\'t affect)', () => {
|
||||
useTimerStore.getState().start();
|
||||
vi.advanceTimersByTime(1000);
|
||||
const split = useTimerStore.getState().split();
|
||||
expect(split.rate).toBe(50);
|
||||
|
||||
useTimerStore.getState().setRate(100);
|
||||
expect(useTimerStore.getState().splits[0].rate).toBe(50);
|
||||
expect(useTimerStore.getState().currentRate).toBe(100);
|
||||
});
|
||||
|
||||
it('setRate only affects live clock, not existing splits', () => {
|
||||
useTimerStore.getState().start();
|
||||
vi.advanceTimersByTime(1000);
|
||||
useTimerStore.getState().split();
|
||||
useTimerStore.getState().split();
|
||||
useTimerStore.getState().setRate(999);
|
||||
expect(useTimerStore.getState().splits.every((s) => s.rate === 50)).toBe(true);
|
||||
});
|
||||
|
||||
it('markRecorded sets flag and stores work entry id', () => {
|
||||
useTimerStore.getState().start();
|
||||
vi.advanceTimersByTime(1000);
|
||||
const split = useTimerStore.getState().split();
|
||||
useTimerStore.getState().markRecorded(split.id, 'we-123');
|
||||
const updated = useTimerStore.getState().splits.find((s) => s.id === split.id)!;
|
||||
expect(updated.recorded).toBe(true);
|
||||
expect(updated.recordedWorkEntryId).toBe('we-123');
|
||||
});
|
||||
|
||||
it('updateSplit modifies fields', () => {
|
||||
useTimerStore.getState().start();
|
||||
vi.advanceTimersByTime(1000);
|
||||
const split = useTimerStore.getState().split();
|
||||
useTimerStore.getState().updateSplit(split.id, { label: 'new label', elapsedMs: 9999 });
|
||||
const updated = useTimerStore.getState().splits.find((s) => s.id === split.id)!;
|
||||
expect(updated.label).toBe('new label');
|
||||
expect(updated.elapsedMs).toBe(9999);
|
||||
});
|
||||
|
||||
it('deleteSplit removes from table', () => {
|
||||
useTimerStore.getState().start();
|
||||
vi.advanceTimersByTime(1000);
|
||||
const split = useTimerStore.getState().split();
|
||||
useTimerStore.getState().deleteSplit(split.id);
|
||||
expect(useTimerStore.getState().splits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reset clears everything', () => {
|
||||
useTimerStore.getState().start();
|
||||
vi.advanceTimersByTime(1000);
|
||||
useTimerStore.getState().split();
|
||||
useTimerStore.getState().reset();
|
||||
expect(useTimerStore.getState().running).toBe(false);
|
||||
expect(useTimerStore.getState().elapsedMs).toBe(0);
|
||||
expect(useTimerStore.getState().splits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('persists state to cookie on tick', () => {
|
||||
useTimerStore.getState().start();
|
||||
vi.advanceTimersByTime(1500);
|
||||
useTimerStore.getState()._tick();
|
||||
expect(document.cookie).toContain('t99_timer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timerStore crash recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('detects crash when heartbeat stale + running', () => {
|
||||
const now = Date.now();
|
||||
const staleSnapshot = {
|
||||
currentRate: 75,
|
||||
running: true,
|
||||
runStartedAt: now - 60000, // started 1 min ago
|
||||
accumulatedMs: 0,
|
||||
splits: [],
|
||||
lastHeartbeat: now - 30000, // last seen 30s ago (> 5s threshold)
|
||||
};
|
||||
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(staleSnapshot))}; path=/`;
|
||||
|
||||
useTimerStore.setState({ crashRecovery: null });
|
||||
useTimerStore.getState()._restoreFromCookie();
|
||||
|
||||
const cr = useTimerStore.getState().crashRecovery;
|
||||
expect(cr).not.toBeNull();
|
||||
expect(cr!.gapMs).toBeGreaterThan(25000);
|
||||
expect(cr!.crashTime).toBe(now - 30000);
|
||||
});
|
||||
|
||||
it('does NOT flag crash when paused', () => {
|
||||
const now = Date.now();
|
||||
const snapshot = {
|
||||
currentRate: 50,
|
||||
running: false,
|
||||
runStartedAt: null,
|
||||
accumulatedMs: 120000,
|
||||
splits: [],
|
||||
lastHeartbeat: now - 60000,
|
||||
};
|
||||
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
|
||||
|
||||
useTimerStore.setState({ crashRecovery: null });
|
||||
useTimerStore.getState()._restoreFromCookie();
|
||||
expect(useTimerStore.getState().crashRecovery).toBeNull();
|
||||
});
|
||||
|
||||
it('restores elapsed time from original start (includes gap)', () => {
|
||||
const now = Date.now();
|
||||
const snapshot = {
|
||||
currentRate: 50,
|
||||
running: true,
|
||||
runStartedAt: now - 120000, // 2 min ago
|
||||
accumulatedMs: 0,
|
||||
splits: [],
|
||||
lastHeartbeat: now - 60000,
|
||||
};
|
||||
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
|
||||
|
||||
useTimerStore.getState()._restoreFromCookie();
|
||||
// Clock should show ~2 minutes (original start was preserved)
|
||||
expect(useTimerStore.getState().elapsedMs).toBeGreaterThanOrEqual(120000);
|
||||
});
|
||||
|
||||
it('subtractCrashGap removes gap from accumulated time', () => {
|
||||
useTimerStore.setState({
|
||||
running: false,
|
||||
runStartedAt: null,
|
||||
accumulatedMs: 100000,
|
||||
crashRecovery: { crashTime: 0, reloadTime: 30000, gapMs: 30000 },
|
||||
elapsedMs: 100000,
|
||||
});
|
||||
useTimerStore.getState().subtractCrashGap();
|
||||
expect(useTimerStore.getState().accumulatedMs).toBe(70000);
|
||||
expect(useTimerStore.getState().crashRecovery).toBeNull();
|
||||
});
|
||||
|
||||
it('subtractCrashGap clamps to zero', () => {
|
||||
useTimerStore.setState({
|
||||
accumulatedMs: 5000,
|
||||
crashRecovery: { crashTime: 0, reloadTime: 0, gapMs: 99999 },
|
||||
});
|
||||
useTimerStore.getState().subtractCrashGap();
|
||||
expect(useTimerStore.getState().accumulatedMs).toBe(0);
|
||||
});
|
||||
|
||||
it('dismissCrashBanner keeps time but removes banner', () => {
|
||||
useTimerStore.setState({
|
||||
accumulatedMs: 100000,
|
||||
crashRecovery: { crashTime: 0, reloadTime: 0, gapMs: 30000 },
|
||||
});
|
||||
useTimerStore.getState().dismissCrashBanner();
|
||||
expect(useTimerStore.getState().crashRecovery).toBeNull();
|
||||
expect(useTimerStore.getState().accumulatedMs).toBe(100000); // unchanged
|
||||
});
|
||||
|
||||
it('restores splits from cookie', () => {
|
||||
const snapshot = {
|
||||
currentRate: 50,
|
||||
running: false,
|
||||
runStartedAt: null,
|
||||
accumulatedMs: 0,
|
||||
splits: [
|
||||
{ id: 's1', startedAt: 0, elapsedMs: 60000, rate: 50, recorded: false },
|
||||
{ id: 's2', startedAt: 0, elapsedMs: 30000, rate: 75, recorded: true, recordedWorkEntryId: 'we-1' },
|
||||
],
|
||||
lastHeartbeat: Date.now(),
|
||||
};
|
||||
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
|
||||
useTimerStore.getState()._restoreFromCookie();
|
||||
expect(useTimerStore.getState().splits).toHaveLength(2);
|
||||
expect(useTimerStore.getState().splits[1].recorded).toBe(true);
|
||||
});
|
||||
});
|
||||
401
src/components/charts/ChartPanel.tsx
Normal file
401
src/components/charts/ChartPanel.tsx
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
/**
|
||||
* Configurable chart panel. Renders one ChartConfig as a Recharts chart,
|
||||
* with inline controls for type, metrics, granularity, and axis ranges.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
ComposedChart,
|
||||
LineChart, Line,
|
||||
BarChart, Bar,
|
||||
Area,
|
||||
PieChart, Pie, Cell,
|
||||
XAxis, YAxis, Tooltip, Legend, CartesianGrid,
|
||||
} from 'recharts';
|
||||
import type { ChartConfig, ChartMetric, ChartType, ChartGranularity } from '@/types';
|
||||
import { buildChartSeries } from '@/lib/stats/aggregate';
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
import { fmtMoneyShort } from '@/lib/format';
|
||||
|
||||
const METRIC_LABELS: Record<ChartMetric, string> = {
|
||||
workValue: 'Work Value',
|
||||
payments: 'Payments',
|
||||
expenses: 'Expenses',
|
||||
netIncome: 'Net Income',
|
||||
cumulativePayments: 'Cumulative Payments',
|
||||
cumulativeNet: 'Cumulative Net',
|
||||
};
|
||||
|
||||
const CHART_COLORS = [
|
||||
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
|
||||
'var(--chart-4)', 'var(--chart-5)',
|
||||
];
|
||||
|
||||
interface Props {
|
||||
config: ChartConfig;
|
||||
onChange: (patch: Partial<ChartConfig>) => void;
|
||||
onRemove?: () => void;
|
||||
defaultRangeStart?: string;
|
||||
defaultRangeEnd?: string;
|
||||
}
|
||||
|
||||
export function ChartPanel({ config, onChange, onRemove, defaultRangeStart, defaultRangeEnd }: Props) {
|
||||
const work = useAppStore((s) => s.data.workEntries);
|
||||
const payments = useAppStore((s) => s.data.payments);
|
||||
const expenses = useAppStore((s) => s.data.expenses);
|
||||
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
|
||||
const rawData = useMemo(
|
||||
() =>
|
||||
buildChartSeries(
|
||||
work,
|
||||
payments,
|
||||
expenses,
|
||||
config.metrics,
|
||||
config.granularity,
|
||||
config.rangeStart ?? defaultRangeStart ?? null,
|
||||
config.rangeEnd ?? defaultRangeEnd ?? null,
|
||||
),
|
||||
[work, payments, expenses, config, defaultRangeStart, defaultRangeEnd],
|
||||
);
|
||||
|
||||
const data = useMemo(() => {
|
||||
const w = config.rollingAvgWindow;
|
||||
|
||||
// For day granularity: fill the full date range so every calendar day is
|
||||
// present (missing days = 0). This makes the window represent actual days,
|
||||
// not just data points, and includes zero-value days in the average.
|
||||
let series = rawData;
|
||||
if (config.granularity === 'day' && rawData.length > 0) {
|
||||
const byDate = new Map(rawData.map((p) => [p.label, p]));
|
||||
const startStr = (config.rangeStart ?? defaultRangeStart) ?? rawData[0].label;
|
||||
const endStr = (config.rangeEnd ?? defaultRangeEnd) ?? rawData[rawData.length - 1].label;
|
||||
const filled: typeof rawData = [];
|
||||
const cur = new Date(startStr + 'T00:00:00');
|
||||
const end = new Date(endStr + 'T00:00:00');
|
||||
while (cur <= end) {
|
||||
const d = cur.toISOString().slice(0, 10);
|
||||
const existing = byDate.get(d);
|
||||
if (existing) {
|
||||
filled.push(existing);
|
||||
} else {
|
||||
const zero: (typeof rawData)[0] = { label: d };
|
||||
for (const m of config.metrics) zero[m] = 0;
|
||||
filled.push(zero);
|
||||
}
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
series = filled;
|
||||
}
|
||||
|
||||
if (!w || w < 1) return series;
|
||||
|
||||
// Compute rolling avg; only emit once a full w-day window is available
|
||||
return series.map((point, i) => {
|
||||
if (i + 1 < w) return point;
|
||||
const result = { ...point };
|
||||
const slice = series.slice(i - w + 1, i + 1);
|
||||
for (const m of config.metrics) {
|
||||
const avg = slice.reduce((s, p) => s + (Number(p[m]) || 0), 0) / w;
|
||||
result[`${m}_avg`] = Math.round(avg * 100) / 100;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}, [rawData, config.rollingAvgWindow, config.granularity, config.metrics,
|
||||
config.rangeStart, config.rangeEnd, defaultRangeStart, defaultRangeEnd]);
|
||||
|
||||
const yDomain: [number | 'auto', number | 'auto'] = [
|
||||
config.yMin ?? 'auto',
|
||||
config.yMax ?? 'auto',
|
||||
];
|
||||
|
||||
const toggleMetric = (m: ChartMetric) => {
|
||||
const has = config.metrics.includes(m);
|
||||
const next = has
|
||||
? config.metrics.filter((x) => x !== m)
|
||||
: [...config.metrics, m];
|
||||
if (next.length > 0) onChange({ metrics: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ flex: 1, minHeight: 300, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="card-header">
|
||||
<input
|
||||
className="input input-inline"
|
||||
style={{ border: 'none', fontWeight: 600, fontSize: 14, background: 'transparent', width: '60%' }}
|
||||
value={config.title ?? ''}
|
||||
placeholder="Chart title"
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => setShowControls(!showControls)} title="Configure">
|
||||
⚙
|
||||
</button>
|
||||
{onRemove && (
|
||||
<button className="btn btn-sm btn-ghost text-danger" onClick={onRemove} title="Remove chart">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showControls && (
|
||||
<div className="flex-col gap-2 mb-4" style={{ padding: 12, background: 'var(--bg-elev-2)', borderRadius: 'var(--radius-sm)' }}>
|
||||
{/* Chart type */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted" style={{ width: 80 }}>Type</span>
|
||||
<div className="btn-group">
|
||||
{(['line', 'bar', 'area', 'pie'] as ChartType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`btn btn-sm ${config.type === t ? 'active' : ''}`}
|
||||
onClick={() => onChange({ type: t })}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Granularity */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted" style={{ width: 80 }}>Grain</span>
|
||||
<div className="btn-group">
|
||||
{(['day', 'week', 'month', 'year'] as ChartGranularity[]).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
className={`btn btn-sm ${config.granularity === g ? 'active' : ''}`}
|
||||
onClick={() => onChange({ granularity: g })}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="flex items-center gap-2" style={{ flexWrap: 'wrap' }}>
|
||||
<span className="text-sm text-muted" style={{ width: 80 }}>Data</span>
|
||||
{(Object.keys(METRIC_LABELS) as ChartMetric[]).map((m) => (
|
||||
<label key={m} className="checkbox text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.metrics.includes(m)}
|
||||
onChange={() => toggleMetric(m)}
|
||||
/>
|
||||
{METRIC_LABELS[m]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* X range */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted" style={{ width: 80 }}>X range</span>
|
||||
<input
|
||||
type="date"
|
||||
className="input input-inline"
|
||||
style={{ width: 140 }}
|
||||
value={config.rangeStart ?? ''}
|
||||
onChange={(e) => onChange({ rangeStart: e.target.value || null })}
|
||||
/>
|
||||
<span className="text-muted">to</span>
|
||||
<input
|
||||
type="date"
|
||||
className="input input-inline"
|
||||
style={{ width: 140 }}
|
||||
value={config.rangeEnd ?? ''}
|
||||
onChange={(e) => onChange({ rangeEnd: e.target.value || null })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Y range */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted" style={{ width: 80 }}>Y range</span>
|
||||
<input
|
||||
type="number"
|
||||
className="input input-inline"
|
||||
style={{ width: 100 }}
|
||||
placeholder="auto"
|
||||
value={config.yMin ?? ''}
|
||||
onChange={(e) => onChange({ yMin: e.target.value ? Number(e.target.value) : null })}
|
||||
/>
|
||||
<span className="text-muted">to</span>
|
||||
<input
|
||||
type="number"
|
||||
className="input input-inline"
|
||||
style={{ width: 100 }}
|
||||
placeholder="auto"
|
||||
value={config.yMax ?? ''}
|
||||
onChange={(e) => onChange({ yMax: e.target.value ? Number(e.target.value) : null })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rolling average */}
|
||||
{(config.type === 'line' || config.type === 'area') && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted" style={{ width: 80 }}>Rolling avg</span>
|
||||
<label className="checkbox text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!config.rollingAvgWindow}
|
||||
onChange={(e) => onChange({ rollingAvgWindow: e.target.checked ? 5 : null })}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
{!!config.rollingAvgWindow && (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
className="input input-inline"
|
||||
style={{ width: 70 }}
|
||||
min={2}
|
||||
max={365}
|
||||
value={config.rollingAvgWindow}
|
||||
onChange={(e) => onChange({ rollingAvgWindow: e.target.value ? Math.max(2, Number(e.target.value)) : null })}
|
||||
/>
|
||||
<span className="text-sm text-muted">periods</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart render */}
|
||||
<div style={{ height: 280 }}>
|
||||
{data.length === 0 ? (
|
||||
<div className="flex items-center justify-between full-height text-muted" style={{ justifyContent: 'center' }}>
|
||||
No data to display
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
{renderChart(config, data, yDomain)}
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderChart(
|
||||
config: ChartConfig,
|
||||
data: ReturnType<typeof buildChartSeries>,
|
||||
yDomain: [number | 'auto', number | 'auto'],
|
||||
) {
|
||||
const common = {
|
||||
data,
|
||||
margin: { top: 5, right: 10, bottom: 5, left: 0 },
|
||||
};
|
||||
|
||||
const axes = (
|
||||
<>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis dataKey="label" stroke="var(--fg-muted)" fontSize={11} />
|
||||
<YAxis
|
||||
stroke="var(--fg-muted)"
|
||||
fontSize={11}
|
||||
domain={yDomain}
|
||||
tickFormatter={(v) => fmtMoneyShort(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
formatter={(v: number) => fmtMoneyShort(v)}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</>
|
||||
);
|
||||
|
||||
switch (config.type) {
|
||||
case 'line':
|
||||
return (
|
||||
<LineChart {...common}>
|
||||
{axes}
|
||||
{config.metrics.map((m, i) => (
|
||||
<Line
|
||||
key={m}
|
||||
type="monotone"
|
||||
dataKey={m}
|
||||
name={METRIC_LABELS[m]}
|
||||
stroke={CHART_COLORS[i % CHART_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
{config.rollingAvgWindow && config.metrics.map((m, i) => (
|
||||
<Line
|
||||
key={`${m}_avg`}
|
||||
type="monotone"
|
||||
dataKey={`${m}_avg`}
|
||||
name={`${METRIC_LABELS[m]} (${config.rollingAvgWindow}d avg)`}
|
||||
stroke={CHART_COLORS[i % CHART_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 3"
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
);
|
||||
case 'bar':
|
||||
return (
|
||||
<BarChart {...common}>
|
||||
{axes}
|
||||
{config.metrics.map((m, i) => (
|
||||
<Bar key={m} dataKey={m} name={METRIC_LABELS[m]} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||
))}
|
||||
</BarChart>
|
||||
);
|
||||
case 'area':
|
||||
return (
|
||||
<ComposedChart {...common}>
|
||||
{axes}
|
||||
{config.metrics.map((m, i) => (
|
||||
<Area
|
||||
key={m}
|
||||
type="monotone"
|
||||
dataKey={m}
|
||||
name={METRIC_LABELS[m]}
|
||||
stroke={CHART_COLORS[i % CHART_COLORS.length]}
|
||||
fill={CHART_COLORS[i % CHART_COLORS.length]}
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
))}
|
||||
{config.rollingAvgWindow && config.metrics.map((m, i) => (
|
||||
<Line
|
||||
key={`${m}_avg`}
|
||||
type="monotone"
|
||||
dataKey={`${m}_avg`}
|
||||
name={`${METRIC_LABELS[m]} (${config.rollingAvgWindow}d avg)`}
|
||||
stroke={CHART_COLORS[i % CHART_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 3"
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</ComposedChart>
|
||||
);
|
||||
case 'pie': {
|
||||
// Pie: sum each metric over the range
|
||||
const totals = config.metrics.map((m) => ({
|
||||
name: METRIC_LABELS[m],
|
||||
value: data.reduce((s, d) => s + (Number(d[m]) || 0), 0),
|
||||
}));
|
||||
return (
|
||||
<PieChart>
|
||||
<Pie data={totals} dataKey="value" nameKey="name" outerRadius="80%" label>
|
||||
{totals.map((_, i) => (
|
||||
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: number) => fmtMoneyShort(v)} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/components/charts/ChartSidebar.tsx
Normal file
66
src/components/charts/ChartSidebar.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Right-side chart column used on data pages.
|
||||
* Shows the user's configured charts + an "Add chart" button.
|
||||
* When `tab` is provided, uses the per-ledger chart arrays instead of the
|
||||
* shared dashboard charts.
|
||||
*/
|
||||
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
import { ChartPanel } from './ChartPanel';
|
||||
|
||||
type PageTab = 'work' | 'payments' | 'expenses' | 'tax';
|
||||
|
||||
export function ChartSidebar({ tab, defaultRangeStart, defaultRangeEnd }: {
|
||||
tab?: PageTab;
|
||||
defaultRangeStart?: string;
|
||||
defaultRangeEnd?: string;
|
||||
}) {
|
||||
const dashCharts = useAppStore((s) => s.data.dashboard.charts);
|
||||
const workCharts = useAppStore((s) => s.data.dashboard.workCharts);
|
||||
const paymentsCharts = useAppStore((s) => s.data.dashboard.paymentsCharts);
|
||||
const expensesCharts = useAppStore((s) => s.data.dashboard.expensesCharts);
|
||||
const taxCharts = useAppStore((s) => s.data.dashboard.taxCharts);
|
||||
const addChart = useAppStore((s) => s.addChart);
|
||||
const updateChart = useAppStore((s) => s.updateChart);
|
||||
const removeChart = useAppStore((s) => s.removeChart);
|
||||
const addLedgerChart = useAppStore((s) => s.addLedgerChart);
|
||||
const updateLedgerChart = useAppStore((s) => s.updateLedgerChart);
|
||||
const removeLedgerChart = useAppStore((s) => s.removeLedgerChart);
|
||||
|
||||
if (tab) {
|
||||
const charts = tab === 'work' ? workCharts : tab === 'payments' ? paymentsCharts : tab === 'expenses' ? expensesCharts : taxCharts;
|
||||
return (
|
||||
<>
|
||||
{(charts ?? []).map((c) => (
|
||||
<ChartPanel
|
||||
key={c.id}
|
||||
config={c}
|
||||
onChange={(patch) => updateLedgerChart(tab, c.id, patch)}
|
||||
onRemove={(charts?.length ?? 0) > 1 ? () => removeLedgerChart(tab, c.id) : undefined}
|
||||
defaultRangeStart={defaultRangeStart}
|
||||
defaultRangeEnd={defaultRangeEnd}
|
||||
/>
|
||||
))}
|
||||
<button className="btn" onClick={() => addLedgerChart(tab)}>
|
||||
+ Add chart
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{dashCharts.map((c) => (
|
||||
<ChartPanel
|
||||
key={c.id}
|
||||
config={c}
|
||||
onChange={(patch) => updateChart(c.id, patch)}
|
||||
onRemove={dashCharts.length > 1 ? () => removeChart(c.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
<button className="btn" onClick={() => addChart()}>
|
||||
+ Add chart
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
src/components/common/Modal.tsx
Normal file
72
src/components/common/Modal.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ open, title, onClose, children, footer }: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="modal-title">{title}</h3>
|
||||
{children}
|
||||
{footer && <div className="modal-footer">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirmation dialog with safe cancel default */
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
danger = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className={danger ? 'btn btn-danger' : 'btn btn-primary'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>{message}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
97
src/components/layout/ResizableSplit.tsx
Normal file
97
src/components/layout/ResizableSplit.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Two-panel resizable split layout.
|
||||
*
|
||||
* Desktop: left | drag-handle | right, with a draggable divider.
|
||||
* Mobile (≤900px): collapsible "Charts" section above, data below.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'split_pct';
|
||||
const DEFAULT_PCT = 50;
|
||||
const MIN_PCT = 20;
|
||||
const MAX_PCT = 80;
|
||||
|
||||
function clamp(v: number) { return Math.max(MIN_PCT, Math.min(MAX_PCT, v)); }
|
||||
|
||||
export function ResizableSplit({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: React.ReactNode;
|
||||
right: React.ReactNode;
|
||||
}) {
|
||||
const [pct, setPct] = useState(() => {
|
||||
const stored = parseFloat(localStorage.getItem(STORAGE_KEY) ?? '');
|
||||
return isNaN(stored) ? DEFAULT_PCT : clamp(stored);
|
||||
});
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [chartsOpen, setChartsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const startDrag = useCallback((clientX: number) => {
|
||||
setDragging(true);
|
||||
// Store initial reference; actual movement handled in effect
|
||||
void clientX;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
|
||||
const move = (clientX: number) => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const next = clamp(((clientX - rect.left) / rect.width) * 100);
|
||||
setPct(next);
|
||||
localStorage.setItem(STORAGE_KEY, String(Math.round(next)));
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => move(e.clientX);
|
||||
const onTouchMove = (e: TouchEvent) => { e.preventDefault(); move(e.touches[0].clientX); };
|
||||
const stop = () => setDragging(false);
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', stop);
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', stop);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', stop);
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', stop);
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`rsplit${dragging ? ' rsplit--dragging' : ''}`}
|
||||
style={{ '--split-pct': `${pct}%` } as React.CSSProperties}
|
||||
>
|
||||
{/* Left / data panel */}
|
||||
<div className="rsplit-left">{left}</div>
|
||||
|
||||
{/* Drag divider — desktop only */}
|
||||
<div
|
||||
className="rsplit-divider"
|
||||
onMouseDown={(e) => { e.preventDefault(); startDrag(e.clientX); }}
|
||||
onTouchStart={(e) => { startDrag(e.touches[0].clientX); }}
|
||||
>
|
||||
<div className="rsplit-divider-handle" />
|
||||
</div>
|
||||
|
||||
{/* Right / charts panel — mobile: hidden unless chartsOpen */}
|
||||
<div className={`rsplit-right${chartsOpen ? ' rsplit-right--open' : ''}`}>{right}</div>
|
||||
|
||||
{/* Mobile-only toggle — sits below everything via CSS order */}
|
||||
<button
|
||||
className="rsplit-toggle"
|
||||
onClick={() => setChartsOpen((o) => !o)}
|
||||
>
|
||||
<span>{chartsOpen ? '▲' : '▼'}</span>
|
||||
Charts
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
309
src/components/spreadsheet/EntryForm.tsx
Normal file
309
src/components/spreadsheet/EntryForm.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
/**
|
||||
* Reusable add/edit entry form. Supports flat-amount OR hours×rate input.
|
||||
*/
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import type { WorkEntry, Payment, Expense } from '@/types';
|
||||
import { todayISO } from '@/lib/format';
|
||||
|
||||
// ─── Payer autocomplete ───────────────────────────────────────────────────────
|
||||
|
||||
function PayerInput({
|
||||
value,
|
||||
onChange,
|
||||
suggestions,
|
||||
placeholder,
|
||||
required,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
suggestions: string[];
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const filtered = suggestions.filter(
|
||||
(s) => !value || s.toLowerCase().includes(value.toLowerCase()),
|
||||
);
|
||||
|
||||
const pick = (s: string) => {
|
||||
onChange(s);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="payer-wrap">
|
||||
<input
|
||||
className="input"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current);
|
||||
setOpen(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
closeTimer.current = setTimeout(() => setOpen(false), 150);
|
||||
}}
|
||||
required={required}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="payer-dropdown">
|
||||
{filtered.map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`payer-option${s === value ? ' active' : ''}`}
|
||||
onMouseDown={() => pick(s)}
|
||||
>
|
||||
{s}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Work Entry ──────────────────────────────────────────────────────────────
|
||||
|
||||
type WorkDraft = Omit<WorkEntry, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
export function WorkEntryForm({
|
||||
initial,
|
||||
defaultRate,
|
||||
existingClients,
|
||||
existingDescriptions,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: Partial<WorkDraft>;
|
||||
defaultRate: number;
|
||||
existingClients?: string[];
|
||||
existingDescriptions?: string[];
|
||||
onSubmit: (d: WorkDraft) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<'amount' | 'time'>(
|
||||
initial?.hours != null ? 'time' : 'amount',
|
||||
);
|
||||
const [date, setDate] = useState(initial?.date ?? todayISO());
|
||||
const [desc, setDesc] = useState(initial?.description ?? '');
|
||||
const [amount, setAmount] = useState(initial?.amount?.toString() ?? '');
|
||||
const [hours, setHours] = useState(initial?.hours?.toString() ?? '');
|
||||
const [rate, setRate] = useState(initial?.rate?.toString() ?? String(defaultRate));
|
||||
const [client, setClient] = useState(initial?.client ?? '');
|
||||
|
||||
const computedValue =
|
||||
mode === 'amount'
|
||||
? parseFloat(amount) || 0
|
||||
: (parseFloat(hours) || 0) * (parseFloat(rate) || 0);
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const base: WorkDraft = { date, description: desc, client: client || undefined };
|
||||
if (mode === 'amount') base.amount = parseFloat(amount) || 0;
|
||||
else {
|
||||
base.hours = parseFloat(hours) || 0;
|
||||
base.rate = parseFloat(rate) || 0;
|
||||
}
|
||||
onSubmit(base);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex-col gap-3">
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Date</label>
|
||||
<input type="date" className="input" value={date} onChange={(e) => setDate(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Client</label>
|
||||
<PayerInput value={client} onChange={setClient} suggestions={existingClients ?? []} placeholder="optional" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Description</label>
|
||||
<PayerInput value={desc} onChange={setDesc} suggestions={existingDescriptions ?? []} placeholder="What did you work on?" />
|
||||
</div>
|
||||
|
||||
<div className="btn-group">
|
||||
<button type="button" className={`btn btn-sm ${mode === 'amount' ? 'active' : ''}`} onClick={() => setMode('amount')}>
|
||||
$ Flat amount
|
||||
</button>
|
||||
<button type="button" className={`btn btn-sm ${mode === 'time' ? 'active' : ''}`} onClick={() => setMode('time')}>
|
||||
⏱ Time × rate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'amount' ? (
|
||||
<div className="field">
|
||||
<label>Amount ($)</label>
|
||||
<input type="number" step="0.01" className="input" value={amount} onChange={(e) => setAmount(e.target.value)} required />
|
||||
</div>
|
||||
) : (
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Hours</label>
|
||||
<input type="number" step="0.01" className="input" value={hours} onChange={(e) => setHours(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Rate ($/hr)</label>
|
||||
<input type="number" step="0.01" className="input" value={rate} onChange={(e) => setRate(e.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-muted text-sm">Value: <span className="mono">${computedValue.toFixed(2)}</span></div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={onCancel}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Payment ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type PaymentDraft = Omit<Payment, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
export function PaymentForm({
|
||||
initial,
|
||||
defaultForm,
|
||||
existingPayers,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: Partial<PaymentDraft>;
|
||||
/** Most recently used form type — used when adding a new payment */
|
||||
defaultForm?: Payment['form'];
|
||||
/** Existing payer names for autocomplete suggestions */
|
||||
existingPayers?: string[];
|
||||
onSubmit: (d: PaymentDraft) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [date, setDate] = useState(initial?.date ?? todayISO());
|
||||
const [amount, setAmount] = useState(initial?.amount?.toString() ?? '');
|
||||
const [payer, setPayer] = useState(initial?.payer ?? '');
|
||||
const [form, setForm] = useState<Payment['form']>(initial?.form ?? defaultForm ?? '1099-NEC');
|
||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit({ date, amount: parseFloat(amount) || 0, payer, form, notes: notes || undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex-col gap-3">
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Date received</label>
|
||||
<input type="date" className="input" value={date} onChange={(e) => setDate(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Amount ($)</label>
|
||||
<input type="number" step="0.01" className="input" value={amount} onChange={(e) => setAmount(e.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Payer</label>
|
||||
<PayerInput
|
||||
required
|
||||
value={payer}
|
||||
onChange={setPayer}
|
||||
suggestions={existingPayers ?? []}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Form</label>
|
||||
<select className="select" value={form} onChange={(e) => setForm(e.target.value as Payment['form'])}>
|
||||
<option value="1099-NEC">1099-NEC</option>
|
||||
<option value="1099-K">1099-K</option>
|
||||
<option value="1099-MISC">1099-MISC</option>
|
||||
<option value="direct">Direct / no form</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Notes</label>
|
||||
<input className="input" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={onCancel}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Expense ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type ExpenseDraft = Omit<Expense, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
export function ExpenseForm({
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: Partial<ExpenseDraft>;
|
||||
onSubmit: (d: ExpenseDraft) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [date, setDate] = useState(initial?.date ?? todayISO());
|
||||
const [amount, setAmount] = useState(initial?.amount?.toString() ?? '');
|
||||
const [desc, setDesc] = useState(initial?.description ?? '');
|
||||
const [deductible, setDeductible] = useState(initial?.deductible ?? true);
|
||||
const [category, setCategory] = useState(initial?.category ?? '');
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
date,
|
||||
amount: parseFloat(amount) || 0,
|
||||
description: desc,
|
||||
deductible,
|
||||
category: category || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex-col gap-3">
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Date</label>
|
||||
<input type="date" className="input" value={date} onChange={(e) => setDate(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Amount ($)</label>
|
||||
<input type="number" step="0.01" className="input" value={amount} onChange={(e) => setAmount(e.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Description</label>
|
||||
<input className="input" value={desc} onChange={(e) => setDesc(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Category</label>
|
||||
<input className="input" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="optional" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label> </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>
|
||||
);
|
||||
}
|
||||
263
src/components/spreadsheet/HierSpreadsheet.tsx
Normal file
263
src/components/spreadsheet/HierSpreadsheet.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
/**
|
||||
* Hierarchical expandable spreadsheet.
|
||||
* Year → Month → Day → Item, with per-row toggles AND global
|
||||
* "expand to level" buttons.
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import type { HierNode } from '@/types';
|
||||
import { fmtMoney } from '@/lib/format';
|
||||
|
||||
export type ExpandLevel = 'year' | 'month' | 'day' | 'item';
|
||||
|
||||
interface Props {
|
||||
nodes: HierNode[];
|
||||
/** Called when user clicks an item-level row (to view/edit) */
|
||||
onView?: (node: HierNode) => void;
|
||||
/** Called when user clicks an item-level row's edit/delete */
|
||||
onEdit?: (node: HierNode) => void;
|
||||
onDelete?: (node: HierNode) => void;
|
||||
/** Called when user clicks "+" on a day row; receives the ISO date string */
|
||||
onAddForDay?: (date: string) => void;
|
||||
/** Label for the value column (e.g. "Earned", "Paid", "Spent") */
|
||||
valueLabel: string;
|
||||
}
|
||||
|
||||
const LEVEL_ORDER: Record<ExpandLevel, number> = {
|
||||
year: 0,
|
||||
month: 1,
|
||||
day: 2,
|
||||
item: 3,
|
||||
};
|
||||
|
||||
/** Add keys for all nodes in the subtree whose level is shallower than maxLevel. */
|
||||
function expandSubtree(node: HierNode, maxLevel: ExpandLevel, keys: Set<string>) {
|
||||
if (LEVEL_ORDER[node.level] < LEVEL_ORDER[maxLevel]) {
|
||||
keys.add(node.key);
|
||||
for (const child of node.children) expandSubtree(child, maxLevel, keys);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the ancestor keys of `target`, or null if not found. */
|
||||
function findAncestorKeys(ns: HierNode[], target: string, path: string[] = []): string[] | null {
|
||||
for (const n of ns) {
|
||||
if (n.key === target) return path;
|
||||
const found = findAncestorKeys(n.children, target, [...path, n.key]);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function filterEmpty(nodes: HierNode[]): HierNode[] {
|
||||
return nodes
|
||||
.filter((n) => n.value !== 0)
|
||||
.map((n) => ({ ...n, children: filterEmpty(n.children) }));
|
||||
}
|
||||
|
||||
export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay, valueLabel }: Props) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [hideEmpty, setHideEmpty] = useState(false);
|
||||
const [selectedLevel, setSelectedLevel] = useState<ExpandLevel>('year');
|
||||
const [scrollToKey, setScrollToKey] = useState<string | null>(null);
|
||||
const rowRefs = useRef<Map<string, HTMLTableRowElement>>(new Map());
|
||||
|
||||
const visibleNodes = useMemo(
|
||||
() => (hideEmpty ? filterEmpty(nodes) : nodes),
|
||||
[nodes, hideEmpty],
|
||||
);
|
||||
|
||||
/** Arrow click — toggle just this node. */
|
||||
const toggle = useCallback((key: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Level-button click — set selected level and apply globally. */
|
||||
const selectLevel = useCallback(
|
||||
(level: ExpandLevel) => {
|
||||
setSelectedLevel(level);
|
||||
if (level === 'year') {
|
||||
setExpanded(new Set());
|
||||
} else {
|
||||
const keys = new Set<string>();
|
||||
for (const n of visibleNodes) expandSubtree(n, level, keys);
|
||||
setExpanded(keys);
|
||||
}
|
||||
},
|
||||
[visibleNodes],
|
||||
);
|
||||
|
||||
/**
|
||||
* Label click — focus: collapse everything, keep ancestors visible, then
|
||||
* expand this node's subtree to selectedLevel.
|
||||
*/
|
||||
const handleLabelClick = useCallback((node: HierNode) => {
|
||||
const ancestors = findAncestorKeys(visibleNodes, node.key) ?? [];
|
||||
const keys = new Set<string>(ancestors);
|
||||
expandSubtree(node, selectedLevel, keys);
|
||||
setExpanded(keys);
|
||||
setScrollToKey(node.key);
|
||||
}, [visibleNodes, selectedLevel]);
|
||||
|
||||
// Scroll focused row to ~1/3 from top after re-render.
|
||||
useEffect(() => {
|
||||
if (!scrollToKey) return;
|
||||
const el = rowRefs.current.get(scrollToKey);
|
||||
if (!el) return;
|
||||
const vh = window.innerHeight;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top >= vh * 0.1 && rect.top <= vh * 0.5) { setScrollToKey(null); return; }
|
||||
window.scrollTo({ top: Math.max(0, window.scrollY + rect.top - vh / 3), behavior: 'smooth' });
|
||||
setScrollToKey(null);
|
||||
}, [scrollToKey]);
|
||||
|
||||
// Flatten to visible rows
|
||||
const rows = useMemo(() => {
|
||||
const out: Array<{ node: HierNode; depth: number }> = [];
|
||||
const walk = (ns: HierNode[], depth: number) => {
|
||||
for (const n of ns) {
|
||||
out.push({ node: n, depth });
|
||||
if (expanded.has(n.key)) walk(n.children, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(visibleNodes, 0);
|
||||
return out;
|
||||
}, [visibleNodes, expanded]);
|
||||
|
||||
const grandTotal = useMemo(
|
||||
() => nodes.reduce((s, n) => s + n.value, 0),
|
||||
[nodes],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-col gap-2">
|
||||
{/* Expand level controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="btn-group">
|
||||
{(['year', 'month', 'day', 'item'] as ExpandLevel[]).map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
className={`btn btn-sm${selectedLevel === lvl ? ' btn-primary' : ''}`}
|
||||
onClick={() => selectLevel(lvl)}
|
||||
>
|
||||
{lvl.charAt(0).toUpperCase() + lvl.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className={`btn btn-sm${hideEmpty ? ' btn-primary' : ''}`}
|
||||
onClick={() => setHideEmpty((v) => !v)}
|
||||
title={hideEmpty ? 'Show all rows' : 'Hide empty rows'}
|
||||
>
|
||||
{hideEmpty ? 'Data only' : 'All rows'}
|
||||
</button>
|
||||
<span className="text-muted text-sm">{rows.length} rows</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period / Item</th>
|
||||
<th className="num">{valueLabel}</th>
|
||||
<th style={{ width: 80 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
No entries yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map(({ node, depth }) => {
|
||||
const isExpanded = expanded.has(node.key);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isItem = node.level === 'item';
|
||||
return (
|
||||
<tr
|
||||
key={node.key}
|
||||
ref={(el) => { el ? rowRefs.current.set(node.key, el) : rowRefs.current.delete(node.key); }}
|
||||
className={clsx('hier-row', `level-${node.level}`, isItem && onView && 'hier-row--clickable')}
|
||||
onClick={isItem && onView ? () => onView(node) : undefined}
|
||||
>
|
||||
<td>
|
||||
<div className="hier-cell">
|
||||
{Array.from({ length: depth }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`hier-indent${i === depth - 1 ? ' branch' : ''}`}
|
||||
/>
|
||||
))}
|
||||
{hasChildren && (
|
||||
<span
|
||||
className={clsx('hier-toggle', isExpanded && 'expanded')}
|
||||
onClick={() => toggle(node.key)}
|
||||
>
|
||||
▸
|
||||
</span>
|
||||
)}
|
||||
{!hasChildren && <span className="hier-toggle" />}
|
||||
{hasChildren
|
||||
? <span className="hier-label" onClick={() => handleLabelClick(node)}>{node.label}</span>
|
||||
: node.label
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="num">{fmtMoney(node.value)}</td>
|
||||
<td className="num" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex gap-1">
|
||||
{node.level === 'day' && onAddForDay && (
|
||||
<button
|
||||
className="btn btn-sm btn-ghost hier-add-btn"
|
||||
onClick={() => onAddForDay(node.key)}
|
||||
title={`Add entry for ${node.label}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
{isItem && onEdit && (
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => onEdit(node)}
|
||||
title="Edit"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{isItem && onDelete && (
|
||||
<button
|
||||
className="btn btn-sm btn-ghost text-danger"
|
||||
onClick={() => onDelete(node)}
|
||||
title="Delete"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>Grand Total</td>
|
||||
<td className="num">{fmtMoney(grandTotal)}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
469
src/components/spreadsheet/InlineSheet.tsx
Normal file
469
src/components/spreadsheet/InlineSheet.tsx
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
/**
|
||||
* Inline-editable spreadsheet for Work and Payment logs.
|
||||
* Click any cell to edit it in place. Date range filter at top.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { WorkEntry, Payment } from '@/types';
|
||||
import { workEntryValue } from '@/types';
|
||||
import { fmtMoney, todayISO } from '@/lib/format';
|
||||
import { ConfirmDialog } from '@/components/common/Modal';
|
||||
|
||||
function yearStart(): string {
|
||||
return `${new Date().getFullYear()}-01-01`;
|
||||
}
|
||||
|
||||
// ─── Shared cell primitives ───────────────────────────────────────────────────
|
||||
|
||||
function ECell({
|
||||
value,
|
||||
type = 'text',
|
||||
display,
|
||||
placeholder = '—',
|
||||
right,
|
||||
mono,
|
||||
onCommit,
|
||||
}: {
|
||||
value: string;
|
||||
type?: 'text' | 'number' | 'date';
|
||||
display?: string;
|
||||
placeholder?: string;
|
||||
right?: boolean;
|
||||
mono?: boolean;
|
||||
onCommit: (v: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const start = () => { setDraft(value); setEditing(true); };
|
||||
const commit = () => {
|
||||
setEditing(false);
|
||||
if (draft !== value) onCommit(draft);
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<td style={{ padding: 0 }}>
|
||||
<input
|
||||
autoFocus
|
||||
type={type}
|
||||
step={type === 'number' ? '0.01' : undefined}
|
||||
value={draft}
|
||||
className="ss-input"
|
||||
style={{ textAlign: right ? 'right' : undefined }}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
if (e.key === 'Escape') setEditing(false);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
const shown = display ?? value;
|
||||
return (
|
||||
<td
|
||||
className={`ss-cell${right ? ' ss-right' : ''}${mono ? ' mono' : ''}`}
|
||||
onClick={start}
|
||||
title="Click to edit"
|
||||
>
|
||||
{shown || <span className="ss-empty">{placeholder}</span>}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
const FORM_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: '1099-NEC', label: '1099-NEC' },
|
||||
{ value: '1099-K', label: '1099-K' },
|
||||
{ value: '1099-MISC', label: '1099-MISC' },
|
||||
{ value: 'direct', label: 'Direct' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
function SCell({
|
||||
value,
|
||||
options,
|
||||
onCommit,
|
||||
}: {
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
onCommit: (v: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
if (editing) {
|
||||
return (
|
||||
<td style={{ padding: 0 }}>
|
||||
<select
|
||||
autoFocus
|
||||
value={value}
|
||||
className="ss-input"
|
||||
onChange={(e) => { onCommit(e.target.value); setEditing(false); }}
|
||||
onBlur={() => setEditing(false)}
|
||||
>
|
||||
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
const label = options.find((o) => o.value === value)?.label ?? value;
|
||||
return (
|
||||
<td className="ss-cell" onClick={() => setEditing(true)} title="Click to edit">
|
||||
{label || <span className="ss-empty">—</span>}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeBar({
|
||||
startDate,
|
||||
onChange,
|
||||
count,
|
||||
total,
|
||||
}: {
|
||||
startDate: string;
|
||||
onChange: (d: string) => void;
|
||||
count: number;
|
||||
total: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="ss-rangebar">
|
||||
<label>From</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input ss-rangebar-input"
|
||||
value={startDate}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<span className="text-muted">to today</span>
|
||||
<span className="ss-rangebar-summary">{count} {count === 1 ? 'row' : 'rows'} · {fmtMoney(total)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Work Sheet ───────────────────────────────────────────────────────────────
|
||||
|
||||
type WorkDraft = {
|
||||
date: string; description: string; client: string;
|
||||
hours: string; rate: string; amount: string;
|
||||
};
|
||||
|
||||
function blankWork(today: string, defaultRate: number): WorkDraft {
|
||||
return { date: today, description: '', client: '', hours: '', rate: String(defaultRate), amount: '' };
|
||||
}
|
||||
|
||||
export function WorkSheet({
|
||||
entries,
|
||||
defaultRate,
|
||||
onAdd,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
entries: WorkEntry[];
|
||||
defaultRate: number;
|
||||
onAdd: (e: Omit<WorkEntry, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||
onUpdate: (id: string, patch: Partial<WorkEntry>) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const today = todayISO();
|
||||
const [startDate, setStartDate] = useState(yearStart);
|
||||
const [draft, setDraft] = useState(() => blankWork(today, defaultRate));
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const filtered = entries
|
||||
.filter((e) => e.date >= startDate && e.date <= today)
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt - a.createdAt);
|
||||
|
||||
const total = filtered.reduce((s, e) => s + workEntryValue(e), 0);
|
||||
|
||||
const draftValue = parseFloat(draft.amount) > 0
|
||||
? parseFloat(draft.amount)
|
||||
: (parseFloat(draft.hours) || 0) * (parseFloat(draft.rate) || 0);
|
||||
|
||||
const addRow = () => {
|
||||
if (!draft.description.trim()) return;
|
||||
const hours = parseFloat(draft.hours) || undefined;
|
||||
const rate = parseFloat(draft.rate) || undefined;
|
||||
const amount = parseFloat(draft.amount) || undefined;
|
||||
onAdd({
|
||||
date: draft.date,
|
||||
description: draft.description.trim(),
|
||||
client: draft.client.trim() || undefined,
|
||||
hours,
|
||||
rate: hours != null ? rate : undefined,
|
||||
amount,
|
||||
});
|
||||
setDraft(blankWork(today, defaultRate));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-col gap-2" style={{ flex: 1, minHeight: 0 }}>
|
||||
<RangeBar startDate={startDate} onChange={setStartDate} count={filtered.length} total={total} />
|
||||
|
||||
<div className="ss-scroll">
|
||||
<table className="ss-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th>Client</th>
|
||||
<th className="ss-right">Hours</th>
|
||||
<th className="ss-right">$/hr</th>
|
||||
<th className="ss-right">Flat $</th>
|
||||
<th className="ss-right">Value</th>
|
||||
<th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Draft / add row at top */}
|
||||
<tr className="ss-draft">
|
||||
<td style={{ padding: 0 }}>
|
||||
<input type="date" className="ss-input" value={draft.date}
|
||||
onChange={(e) => setDraft({ ...draft, date: e.target.value })} />
|
||||
</td>
|
||||
<td style={{ padding: 0 }}>
|
||||
<input className="ss-input" placeholder="Description…" value={draft.description}
|
||||
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') addRow(); }} />
|
||||
</td>
|
||||
<td style={{ padding: 0 }}>
|
||||
<input className="ss-input" placeholder="Client" value={draft.client}
|
||||
onChange={(e) => setDraft({ ...draft, client: e.target.value })} />
|
||||
</td>
|
||||
<td style={{ padding: 0 }}>
|
||||
<input type="number" className="ss-input ss-right" placeholder="hrs" value={draft.hours}
|
||||
onChange={(e) => setDraft({ ...draft, hours: e.target.value })} />
|
||||
</td>
|
||||
<td style={{ padding: 0 }}>
|
||||
<input type="number" className="ss-input ss-right" placeholder="rate" value={draft.rate}
|
||||
onChange={(e) => setDraft({ ...draft, rate: e.target.value })} />
|
||||
</td>
|
||||
<td style={{ padding: 0 }}>
|
||||
<input type="number" className="ss-input ss-right" placeholder="flat $" value={draft.amount}
|
||||
onChange={(e) => setDraft({ ...draft, amount: e.target.value })} />
|
||||
</td>
|
||||
<td className="ss-right mono ss-value text-muted">
|
||||
{draftValue > 0 ? fmtMoney(draftValue) : '—'}
|
||||
</td>
|
||||
<td className="ss-actions">
|
||||
<button className="btn btn-sm btn-primary" onClick={addRow}
|
||||
disabled={!draft.description.trim()} title="Add row">
|
||||
+
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
No entries in this period
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{filtered.map((e) => {
|
||||
const val = workEntryValue(e);
|
||||
return (
|
||||
<tr key={e.id} className="ss-row">
|
||||
<ECell value={e.date} type="date"
|
||||
onCommit={(v) => onUpdate(e.id, { date: v })} />
|
||||
<ECell value={e.description}
|
||||
onCommit={(v) => onUpdate(e.id, { description: v })} />
|
||||
<ECell value={e.client ?? ''} placeholder="—"
|
||||
onCommit={(v) => onUpdate(e.id, { client: v.trim() || undefined })} />
|
||||
<ECell value={e.hours?.toString() ?? ''} type="number" right placeholder="—"
|
||||
onCommit={(v) => {
|
||||
const hours = parseFloat(v) || undefined;
|
||||
onUpdate(e.id, { hours, amount: hours != null ? undefined : e.amount });
|
||||
}} />
|
||||
<ECell value={e.rate?.toString() ?? ''} type="number" right placeholder="—"
|
||||
onCommit={(v) => onUpdate(e.id, { rate: parseFloat(v) || undefined })} />
|
||||
<ECell value={e.amount?.toString() ?? ''} type="number" right placeholder="—"
|
||||
onCommit={(v) => {
|
||||
const amount = parseFloat(v) || undefined;
|
||||
onUpdate(e.id, {
|
||||
amount,
|
||||
hours: amount != null ? undefined : e.hours,
|
||||
rate: amount != null ? undefined : e.rate,
|
||||
});
|
||||
}} />
|
||||
<td className="ss-right mono ss-value">{fmtMoney(val)}</td>
|
||||
<td className="ss-actions">
|
||||
<button className="btn btn-sm btn-ghost text-danger"
|
||||
onClick={() => setDeleting(e.id)} title="Delete">
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="ss-total">
|
||||
<td colSpan={6}>Total</td>
|
||||
<td className="ss-right mono">{fmtMoney(total)}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting != null}
|
||||
title="Delete work entry?"
|
||||
message="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={() => { if (deleting) onDelete(deleting); setDeleting(null); }}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Payment Sheet ────────────────────────────────────────────────────────────
|
||||
|
||||
type PaymentDraft = { date: string; payer: string; amount: string; form: string; notes: string };
|
||||
|
||||
function blankPayment(today: string): PaymentDraft {
|
||||
return { date: today, payer: '', amount: '', form: '1099-NEC', notes: '' };
|
||||
}
|
||||
|
||||
export function PaymentSheet({
|
||||
payments,
|
||||
onAdd,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
payments: Payment[];
|
||||
onAdd: (p: Omit<Payment, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||
onUpdate: (id: string, patch: Partial<Payment>) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const today = todayISO();
|
||||
const [startDate, setStartDate] = useState(yearStart);
|
||||
const [draft, setDraft] = useState(() => blankPayment(today));
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const filtered = payments
|
||||
.filter((p) => p.date >= startDate && p.date <= today)
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt - a.createdAt);
|
||||
|
||||
const total = filtered.reduce((s, p) => s + p.amount, 0);
|
||||
|
||||
const addRow = () => {
|
||||
const amount = parseFloat(draft.amount);
|
||||
if (!draft.payer.trim() || !amount) return;
|
||||
onAdd({
|
||||
date: draft.date,
|
||||
payer: draft.payer.trim(),
|
||||
amount,
|
||||
form: draft.form as Payment['form'],
|
||||
notes: draft.notes.trim() || undefined,
|
||||
});
|
||||
setDraft(blankPayment(today));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-col gap-2" style={{ flex: 1, minHeight: 0 }}>
|
||||
<RangeBar startDate={startDate} onChange={setStartDate} count={filtered.length} total={total} />
|
||||
|
||||
<div className="ss-scroll">
|
||||
<table className="ss-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Payer</th>
|
||||
<th className="ss-right">Amount</th>
|
||||
<th>Form</th>
|
||||
<th>Notes</th>
|
||||
<th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Draft / add row */}
|
||||
<tr className="ss-draft">
|
||||
<td style={{ padding: 0 }}>
|
||||
<input type="date" className="ss-input" value={draft.date}
|
||||
onChange={(e) => setDraft({ ...draft, date: e.target.value })} />
|
||||
</td>
|
||||
<td style={{ padding: 0 }}>
|
||||
<input className="ss-input" placeholder="Payer…" value={draft.payer}
|
||||
onChange={(e) => setDraft({ ...draft, payer: e.target.value })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') addRow(); }} />
|
||||
</td>
|
||||
<td style={{ padding: 0 }}>
|
||||
<input type="number" step="0.01" className="ss-input ss-right" placeholder="0.00"
|
||||
value={draft.amount}
|
||||
onChange={(e) => setDraft({ ...draft, amount: e.target.value })} />
|
||||
</td>
|
||||
<td style={{ padding: 0 }}>
|
||||
<select className="ss-input" value={draft.form}
|
||||
onChange={(e) => setDraft({ ...draft, form: e.target.value })}>
|
||||
{FORM_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td style={{ padding: 0 }}>
|
||||
<input className="ss-input" placeholder="Notes" value={draft.notes}
|
||||
onChange={(e) => setDraft({ ...draft, notes: e.target.value })} />
|
||||
</td>
|
||||
<td className="ss-actions">
|
||||
<button className="btn btn-sm btn-primary" onClick={addRow}
|
||||
disabled={!draft.payer.trim() || !parseFloat(draft.amount)} title="Add row">
|
||||
+
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
No payments in this period
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{filtered.map((p) => (
|
||||
<tr key={p.id} className="ss-row">
|
||||
<ECell value={p.date} type="date"
|
||||
onCommit={(v) => onUpdate(p.id, { date: v })} />
|
||||
<ECell value={p.payer}
|
||||
onCommit={(v) => onUpdate(p.id, { payer: v })} />
|
||||
<ECell value={p.amount.toString()} type="number"
|
||||
display={fmtMoney(p.amount)} right mono
|
||||
onCommit={(v) => onUpdate(p.id, { amount: parseFloat(v) || 0 })} />
|
||||
<SCell value={p.form ?? 'direct'} options={FORM_OPTIONS}
|
||||
onCommit={(v) => onUpdate(p.id, { form: v as Payment['form'] })} />
|
||||
<ECell value={p.notes ?? ''} placeholder="—"
|
||||
onCommit={(v) => onUpdate(p.id, { notes: v.trim() || undefined })} />
|
||||
<td className="ss-actions">
|
||||
<button className="btn btn-sm btn-ghost text-danger"
|
||||
onClick={() => setDeleting(p.id)} title="Delete">
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="ss-total">
|
||||
<td colSpan={4}>Total</td>
|
||||
<td className="ss-right mono">{fmtMoney(total)}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting != null}
|
||||
title="Delete payment?"
|
||||
message="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={() => { if (deleting) onDelete(deleting); setDeleting(null); }}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
src/lib/crypto/encryption.ts
Normal file
121
src/lib/crypto/encryption.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Client-side encryption using the Web Crypto API.
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* - Password → PBKDF2 (SHA-256, 100k iterations) → AES-256-GCM key
|
||||
* - Output: base64(salt || iv || ciphertext)
|
||||
*
|
||||
* This key is derived in-browser and NEVER leaves the client, whether
|
||||
* storing to cookies, file download, or the cloud blob store.
|
||||
*/
|
||||
|
||||
const PBKDF2_ITERATIONS = 100_000;
|
||||
const SALT_LEN = 16;
|
||||
const IV_LEN = 12;
|
||||
|
||||
/**
|
||||
* Derive an AES-GCM key from a password + salt.
|
||||
* Exported so we can cache the CryptoKey after login and avoid re-deriving
|
||||
* on every save (PBKDF2 is intentionally slow).
|
||||
*/
|
||||
export async function deriveKey(
|
||||
password: string,
|
||||
salt: Uint8Array,
|
||||
): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey'],
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: salt as BufferSource,
|
||||
iterations: PBKDF2_ITERATIONS,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
baseKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
/** Encrypt a string with a password. Returns base64(salt||iv||ct). */
|
||||
export async function encrypt(
|
||||
plaintext: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
|
||||
const key = await deriveKey(password, salt);
|
||||
const enc = new TextEncoder();
|
||||
const ct = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||
key,
|
||||
enc.encode(plaintext),
|
||||
);
|
||||
return b64encode(concat(salt, iv, new Uint8Array(ct)));
|
||||
}
|
||||
|
||||
/** Decrypt base64(salt||iv||ct). Throws if password is wrong (GCM auth fail). */
|
||||
export async function decrypt(
|
||||
ciphertext: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
const buf = b64decode(ciphertext);
|
||||
const salt = buf.slice(0, SALT_LEN);
|
||||
const iv = buf.slice(SALT_LEN, SALT_LEN + IV_LEN);
|
||||
const ct = buf.slice(SALT_LEN + IV_LEN);
|
||||
const key = await deriveKey(password, salt);
|
||||
const pt = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||
key,
|
||||
ct as BufferSource,
|
||||
);
|
||||
return new TextDecoder().decode(pt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password can decrypt a given ciphertext without throwing.
|
||||
* Used on the login screen.
|
||||
*/
|
||||
export async function verifyPassword(
|
||||
ciphertext: string,
|
||||
password: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await decrypt(ciphertext, password);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── bytes helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function concat(...arrs: Uint8Array[]): Uint8Array {
|
||||
const len = arrs.reduce((s, a) => s + a.length, 0);
|
||||
const out = new Uint8Array(len);
|
||||
let off = 0;
|
||||
for (const a of arrs) {
|
||||
out.set(a, off);
|
||||
off += a.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function b64encode(buf: Uint8Array): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < buf.length; i++) s += String.fromCharCode(buf[i]);
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
function b64decode(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const buf = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
||||
return buf;
|
||||
}
|
||||
60
src/lib/format.ts
Normal file
60
src/lib/format.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/** Formatting helpers */
|
||||
|
||||
export function fmtMoney(n: number | null | undefined): string {
|
||||
if (n == null || !Number.isFinite(n)) return '—';
|
||||
return n.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function fmtMoneyShort(n: number | null | undefined): string {
|
||||
if (n == null || !Number.isFinite(n)) return '—';
|
||||
return n.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** Format milliseconds as H:MM:SS */
|
||||
export function fmtDuration(ms: number): string {
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Format milliseconds as Hh Mm Ss (verbose) */
|
||||
export function fmtDurationVerbose(ms: number): string {
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${h}h ${m}m ${s}s`;
|
||||
}
|
||||
|
||||
/** Total minutes from milliseconds, ignoring leftover seconds (h*60 + m) */
|
||||
export function totalMinutes(ms: number): number {
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
/** Milliseconds → decimal hours */
|
||||
export function msToHours(ms: number): number {
|
||||
return ms / 3_600_000;
|
||||
}
|
||||
|
||||
export function fmtPct(n: number): string {
|
||||
return (n * 100).toFixed(1) + '%';
|
||||
}
|
||||
|
||||
export function todayISO(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
6
src/lib/id.ts
Normal file
6
src/lib/id.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/** Tiny collision-resistant id generator (FOSS-friendly, no deps) */
|
||||
export function uid(): string {
|
||||
const t = Date.now().toString(36);
|
||||
const r = Math.random().toString(36).slice(2, 10);
|
||||
return `${t}-${r}`;
|
||||
}
|
||||
377
src/lib/stats/aggregate.ts
Normal file
377
src/lib/stats/aggregate.ts
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
/**
|
||||
* Statistics & Projection Engine
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Aggregates work entries, payments, and expenses at year/month/day levels,
|
||||
* computes averages per child period, and projects end-of-period totals
|
||||
* from run rates.
|
||||
*/
|
||||
|
||||
import type {
|
||||
WorkEntry,
|
||||
Payment,
|
||||
Expense,
|
||||
PeriodStats,
|
||||
HierNode,
|
||||
ChartMetric,
|
||||
ChartGranularity,
|
||||
ISODate,
|
||||
} from '@/types';
|
||||
import { workEntryValue } from '@/types';
|
||||
import {
|
||||
getDaysInMonth,
|
||||
format,
|
||||
startOfWeek,
|
||||
addDays,
|
||||
} from 'date-fns';
|
||||
|
||||
interface DatedValue {
|
||||
date: ISODate;
|
||||
value: number;
|
||||
}
|
||||
|
||||
// ─── Core aggregation ────────────────────────────────────────────────────────
|
||||
|
||||
export interface Aggregates {
|
||||
years: PeriodStats[];
|
||||
months: Map<string, PeriodStats>; // key: YYYY-MM
|
||||
days: Map<string, PeriodStats>; // key: YYYY-MM-DD
|
||||
}
|
||||
|
||||
export function aggregate(
|
||||
work: WorkEntry[],
|
||||
payments: Payment[],
|
||||
expenses: Expense[],
|
||||
asOf: Date = new Date(),
|
||||
): Aggregates {
|
||||
// Index everything by day first
|
||||
const days = new Map<string, PeriodStats>();
|
||||
const touch = (date: ISODate) => {
|
||||
if (!days.has(date)) {
|
||||
days.set(date, blank(date));
|
||||
}
|
||||
return days.get(date)!;
|
||||
};
|
||||
|
||||
for (const w of work) {
|
||||
touch(w.date).workValue += workEntryValue(w);
|
||||
}
|
||||
for (const p of payments) {
|
||||
touch(p.date).payments += p.amount;
|
||||
}
|
||||
for (const e of expenses) {
|
||||
const d = touch(e.date);
|
||||
d.expenses += e.amount;
|
||||
if (e.deductible) d.deductibleExpenses += e.amount;
|
||||
}
|
||||
for (const d of days.values()) {
|
||||
d.net = d.payments - d.expenses;
|
||||
d.childCount = 1; // days have 1 implicit child (themselves) for avg purposes
|
||||
}
|
||||
|
||||
// Roll up to months
|
||||
const months = new Map<string, PeriodStats>();
|
||||
for (const [date, d] of days) {
|
||||
const mk = date.slice(0, 7);
|
||||
if (!months.has(mk)) months.set(mk, blank(mk));
|
||||
const m = months.get(mk)!;
|
||||
m.workValue += d.workValue;
|
||||
m.payments += d.payments;
|
||||
m.expenses += d.expenses;
|
||||
m.deductibleExpenses += d.deductibleExpenses;
|
||||
m.childCount += 1;
|
||||
}
|
||||
for (const [mk, m] of months) {
|
||||
m.net = m.payments - m.expenses;
|
||||
m.avgPerChild = m.childCount > 0 ? m.net / m.childCount : null;
|
||||
// Projection: if this is the current month, scale by days remaining
|
||||
const [y, mo] = mk.split('-').map(Number);
|
||||
if (y === asOf.getFullYear() && mo === asOf.getMonth() + 1) {
|
||||
const daysInMonth = getDaysInMonth(new Date(y, mo - 1));
|
||||
const dayOfMonth = asOf.getDate();
|
||||
if (dayOfMonth < daysInMonth && dayOfMonth > 0) {
|
||||
m.projected = (m.net / dayOfMonth) * daysInMonth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Roll up to years
|
||||
const yearMap = new Map<string, PeriodStats>();
|
||||
for (const [mk, m] of months) {
|
||||
const yk = mk.slice(0, 4);
|
||||
if (!yearMap.has(yk)) yearMap.set(yk, blank(yk));
|
||||
const y = yearMap.get(yk)!;
|
||||
y.workValue += m.workValue;
|
||||
y.payments += m.payments;
|
||||
y.expenses += m.expenses;
|
||||
y.deductibleExpenses += m.deductibleExpenses;
|
||||
y.childCount += 1;
|
||||
}
|
||||
for (const [yk, y] of yearMap) {
|
||||
y.net = y.payments - y.expenses;
|
||||
y.avgPerChild = y.childCount > 0 ? y.net / y.childCount : null;
|
||||
// Projection for current year
|
||||
const yr = Number(yk);
|
||||
if (yr === asOf.getFullYear()) {
|
||||
const frac = fractionOfYearElapsed(yr, asOf);
|
||||
if (frac > 0 && frac < 1) y.projected = y.net / frac;
|
||||
}
|
||||
}
|
||||
|
||||
const years = [...yearMap.values()].sort((a, b) =>
|
||||
b.label.localeCompare(a.label),
|
||||
);
|
||||
|
||||
return { years, months, days };
|
||||
}
|
||||
|
||||
function blank(label: string): PeriodStats {
|
||||
return {
|
||||
label,
|
||||
workValue: 0,
|
||||
payments: 0,
|
||||
expenses: 0,
|
||||
deductibleExpenses: 0,
|
||||
net: 0,
|
||||
avgPerChild: null,
|
||||
projected: null,
|
||||
childCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function fractionOfYearElapsed(year: number, asOf: Date): number {
|
||||
const start = new Date(year, 0, 1).getTime();
|
||||
const end = new Date(year + 1, 0, 1).getTime();
|
||||
const now = Math.min(Math.max(asOf.getTime(), start), end);
|
||||
return (now - start) / (end - start);
|
||||
}
|
||||
|
||||
// ─── Hierarchical tree builder (for the expandable spreadsheet) ──────────────
|
||||
|
||||
export function buildHierarchy(
|
||||
entries: Array<WorkEntry | Payment | Expense>,
|
||||
valueOf: (e: WorkEntry | Payment | Expense) => number,
|
||||
labelOf: (e: WorkEntry | Payment | Expense) => string,
|
||||
): HierNode[] {
|
||||
// Group by year > month > day > item
|
||||
const byYear = new Map<string, Map<string, Map<string, Array<WorkEntry | Payment | Expense>>>>();
|
||||
for (const e of entries) {
|
||||
const y = e.date.slice(0, 4);
|
||||
const m = e.date.slice(0, 7);
|
||||
const d = e.date;
|
||||
if (!byYear.has(y)) byYear.set(y, new Map());
|
||||
const ym = byYear.get(y)!;
|
||||
if (!ym.has(m)) ym.set(m, new Map());
|
||||
const md = ym.get(m)!;
|
||||
if (!md.has(d)) md.set(d, []);
|
||||
md.get(d)!.push(e);
|
||||
}
|
||||
|
||||
const years: HierNode[] = [];
|
||||
for (const [y, monthMap] of [...byYear].sort((a, b) => b[0].localeCompare(a[0]))) {
|
||||
const months: HierNode[] = [];
|
||||
let yearTotal = 0;
|
||||
for (const [m, dayMap] of [...monthMap].sort((a, b) => b[0].localeCompare(a[0]))) {
|
||||
const days: HierNode[] = [];
|
||||
let monthTotal = 0;
|
||||
for (const [d, items] of [...dayMap].sort((a, b) => b[0].localeCompare(a[0]))) {
|
||||
const itemNodes: HierNode[] = items.map((e) => ({
|
||||
key: e.id,
|
||||
level: 'item' as const,
|
||||
label: labelOf(e),
|
||||
value: valueOf(e),
|
||||
children: [],
|
||||
entry: e,
|
||||
}));
|
||||
const dayTotal = itemNodes.reduce((s, n) => s + n.value, 0);
|
||||
monthTotal += dayTotal;
|
||||
days.push({
|
||||
key: d,
|
||||
level: 'day',
|
||||
label: format(new Date(d + 'T00:00:00'), 'EEE, MMM d'),
|
||||
value: dayTotal,
|
||||
children: itemNodes,
|
||||
});
|
||||
}
|
||||
yearTotal += monthTotal;
|
||||
months.push({
|
||||
key: m,
|
||||
level: 'month',
|
||||
label: format(new Date(m + '-01T00:00:00'), 'MMMM yyyy'),
|
||||
value: monthTotal,
|
||||
children: days,
|
||||
});
|
||||
}
|
||||
years.push({
|
||||
key: y,
|
||||
level: 'year',
|
||||
label: y,
|
||||
value: yearTotal,
|
||||
children: months,
|
||||
});
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
// ─── Hierarchy with full date range (including empty days) ───────────────────
|
||||
|
||||
/**
|
||||
* Like buildHierarchy but generates a node for EVERY day between startDate
|
||||
* and endDate (inclusive), even days with no entries. Entries outside the
|
||||
* range are ignored. Dates are descending (newest first).
|
||||
*/
|
||||
export function buildHierarchyForRange(
|
||||
entries: Array<WorkEntry | Payment | Expense>,
|
||||
startDate: ISODate,
|
||||
endDate: ISODate,
|
||||
valueOf: (e: WorkEntry | Payment | Expense) => number,
|
||||
labelOf: (e: WorkEntry | Payment | Expense) => string,
|
||||
): HierNode[] {
|
||||
// Index in-range entries by date
|
||||
const byDate = new Map<string, Array<WorkEntry | Payment | Expense>>();
|
||||
for (const e of entries) {
|
||||
if (e.date >= startDate && e.date <= endDate) {
|
||||
if (!byDate.has(e.date)) byDate.set(e.date, []);
|
||||
byDate.get(e.date)!.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate all dates descending
|
||||
const dates: string[] = [];
|
||||
const cur = new Date(endDate + 'T00:00:00');
|
||||
const start = new Date(startDate + 'T00:00:00');
|
||||
while (cur >= start) {
|
||||
dates.push(cur.toISOString().slice(0, 10));
|
||||
cur.setDate(cur.getDate() - 1);
|
||||
}
|
||||
|
||||
// Group into year > month buckets (order is already descending)
|
||||
const yearBuckets: Array<{ year: string; months: Array<{ month: string; dates: string[] }> }> = [];
|
||||
const yearIdx = new Map<string, (typeof yearBuckets)[0]>();
|
||||
for (const date of dates) {
|
||||
const y = date.slice(0, 4);
|
||||
const m = date.slice(0, 7);
|
||||
if (!yearIdx.has(y)) {
|
||||
const yb = { year: y, months: [] };
|
||||
yearBuckets.push(yb);
|
||||
yearIdx.set(y, yb);
|
||||
}
|
||||
const yb = yearIdx.get(y)!;
|
||||
let mb = yb.months.find((x) => x.month === m);
|
||||
if (!mb) { mb = { month: m, dates: [] }; yb.months.push(mb); }
|
||||
mb.dates.push(date);
|
||||
}
|
||||
|
||||
// Build HierNode tree
|
||||
return yearBuckets.map(({ year, months }) => {
|
||||
const monthNodes = months.map(({ month, dates: ds }) => {
|
||||
const dayNodes: HierNode[] = ds.map((d) => {
|
||||
const items = byDate.get(d) ?? [];
|
||||
const itemNodes: HierNode[] = items.map((e) => ({
|
||||
key: e.id,
|
||||
level: 'item',
|
||||
label: labelOf(e),
|
||||
value: valueOf(e),
|
||||
children: [],
|
||||
entry: e,
|
||||
}));
|
||||
return {
|
||||
key: d,
|
||||
level: 'day',
|
||||
label: format(new Date(d + 'T00:00:00'), 'EEE, MMM d'),
|
||||
value: itemNodes.reduce((s, n) => s + n.value, 0),
|
||||
children: itemNodes,
|
||||
};
|
||||
});
|
||||
return {
|
||||
key: month,
|
||||
level: 'month',
|
||||
label: format(new Date(month + '-01T00:00:00'), 'MMMM yyyy'),
|
||||
value: dayNodes.reduce((s, n) => s + n.value, 0),
|
||||
children: dayNodes,
|
||||
};
|
||||
});
|
||||
return {
|
||||
key: year,
|
||||
level: 'year',
|
||||
label: year,
|
||||
value: monthNodes.reduce((s, n) => s + n.value, 0),
|
||||
children: monthNodes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Chart data series ───────────────────────────────────────────────────────
|
||||
|
||||
export interface ChartPoint {
|
||||
label: string;
|
||||
[metric: string]: number | string;
|
||||
}
|
||||
|
||||
export function buildChartSeries(
|
||||
work: WorkEntry[],
|
||||
payments: Payment[],
|
||||
expenses: Expense[],
|
||||
metrics: ChartMetric[],
|
||||
granularity: ChartGranularity,
|
||||
rangeStart: ISODate | null,
|
||||
rangeEnd: ISODate | null,
|
||||
): ChartPoint[] {
|
||||
const agg = aggregate(work, payments, expenses);
|
||||
|
||||
// Pick source map based on granularity
|
||||
let points: Array<{ key: string; stats: PeriodStats }>;
|
||||
if (granularity === 'year') {
|
||||
points = agg.years.map((y) => ({ key: y.label, stats: y }));
|
||||
} else if (granularity === 'month') {
|
||||
points = [...agg.months.entries()].map(([k, s]) => ({ key: k, stats: s }));
|
||||
} else if (granularity === 'day') {
|
||||
points = [...agg.days.entries()].map(([k, s]) => ({ key: k, stats: s }));
|
||||
} else {
|
||||
// week: group days by ISO week
|
||||
const weekMap = new Map<string, PeriodStats>();
|
||||
for (const [k, s] of agg.days) {
|
||||
const d = new Date(k + 'T00:00:00');
|
||||
const ws = startOfWeek(d, { weekStartsOn: 1 });
|
||||
const wk = format(ws, 'yyyy-MM-dd');
|
||||
if (!weekMap.has(wk)) weekMap.set(wk, blank(wk));
|
||||
const w = weekMap.get(wk)!;
|
||||
w.workValue += s.workValue;
|
||||
w.payments += s.payments;
|
||||
w.expenses += s.expenses;
|
||||
w.net += s.net;
|
||||
}
|
||||
points = [...weekMap.entries()].map(([k, s]) => ({ key: k, stats: s }));
|
||||
}
|
||||
|
||||
// Filter by range
|
||||
if (rangeStart) points = points.filter((p) => p.key >= rangeStart);
|
||||
if (rangeEnd) points = points.filter((p) => p.key <= rangeEnd);
|
||||
|
||||
// Sort ascending for charts
|
||||
points.sort((a, b) => a.key.localeCompare(b.key));
|
||||
|
||||
// Cumulative state
|
||||
let cumPayments = 0;
|
||||
let cumNet = 0;
|
||||
|
||||
return points.map((p) => {
|
||||
cumPayments += p.stats.payments;
|
||||
cumNet += p.stats.net;
|
||||
const out: ChartPoint = { label: p.key };
|
||||
for (const m of metrics) {
|
||||
switch (m) {
|
||||
case 'workValue': out[m] = round2(p.stats.workValue); break;
|
||||
case 'payments': out[m] = round2(p.stats.payments); break;
|
||||
case 'expenses': out[m] = round2(p.stats.expenses); break;
|
||||
case 'netIncome': out[m] = round2(p.stats.net); break;
|
||||
case 'cumulativePayments': out[m] = round2(cumPayments); break;
|
||||
case 'cumulativeNet': out[m] = round2(cumNet); break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
151
src/lib/storage/adapters.ts
Normal file
151
src/lib/storage/adapters.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Storage Adapters
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Two backends, one interface. All backends store an ENCRYPTED blob;
|
||||
* encryption happens in the caller (store layer) before hitting any adapter.
|
||||
*
|
||||
* - CookieStorage: primary storage — chunked across browser cookies
|
||||
* - FileStorage: used only for manual import/export, not live persistence
|
||||
*/
|
||||
|
||||
export interface StorageAdapter {
|
||||
save(encryptedBlob: string): Promise<void>;
|
||||
load(): Promise<string | null>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
// ─── Cookie ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cookies have a ~4KB limit per cookie. We chunk the encrypted blob across
|
||||
* multiple cookies. For realistic datasets this is fine since the blob is
|
||||
* encrypted + JSON; a heavy user might hit ~20KB → 5 cookies.
|
||||
*
|
||||
* We also use localStorage as a mirror — cookies are the primary (so the
|
||||
* blob survives localStorage being cleared), localStorage is the fast path.
|
||||
*/
|
||||
const COOKIE_PREFIX = 't99_';
|
||||
const COOKIE_CHUNK_SIZE = 3800;
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 2; // 2 years
|
||||
|
||||
export class CookieStorage implements StorageAdapter {
|
||||
constructor(private namespace: string) {}
|
||||
|
||||
private key(i: number | 'count'): string {
|
||||
return `${COOKIE_PREFIX}${this.namespace}_${i}`;
|
||||
}
|
||||
|
||||
async save(blob: string): Promise<void> {
|
||||
// Mirror to localStorage for fast reads
|
||||
try {
|
||||
localStorage.setItem(this.key('count'), blob);
|
||||
} catch {
|
||||
/* quota exceeded — cookies still work */
|
||||
}
|
||||
// Clear old chunks first
|
||||
const oldCount = this.readCookieInt(this.key('count'));
|
||||
for (let i = 0; i < oldCount; i++) this.deleteCookie(this.key(i));
|
||||
// Write new chunks
|
||||
const chunks = chunkString(blob, COOKIE_CHUNK_SIZE);
|
||||
chunks.forEach((c, i) => this.writeCookie(this.key(i), c));
|
||||
this.writeCookie(this.key('count'), String(chunks.length));
|
||||
}
|
||||
|
||||
async load(): Promise<string | null> {
|
||||
// Fast path
|
||||
try {
|
||||
const ls = localStorage.getItem(this.key('count'));
|
||||
if (ls && ls.length > 10) return ls; // heuristic: actual blob, not a count
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Cookie reassembly
|
||||
const count = this.readCookieInt(this.key('count'));
|
||||
if (count === 0) return null;
|
||||
let out = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const c = this.readCookie(this.key(i));
|
||||
if (c == null) return null; // corrupted
|
||||
out += c;
|
||||
}
|
||||
return out || null;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
localStorage.removeItem(this.key('count'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const count = this.readCookieInt(this.key('count'));
|
||||
for (let i = 0; i < count; i++) this.deleteCookie(this.key(i));
|
||||
this.deleteCookie(this.key('count'));
|
||||
}
|
||||
|
||||
private writeCookie(name: string, value: string): void {
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; max-age=${COOKIE_MAX_AGE}; path=/; SameSite=Strict`;
|
||||
}
|
||||
|
||||
private readCookie(name: string): string | null {
|
||||
const match = document.cookie.match(
|
||||
new RegExp(`(?:^|; )${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}=([^;]*)`),
|
||||
);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
private readCookieInt(name: string): number {
|
||||
const v = this.readCookie(name);
|
||||
const n = v ? parseInt(v, 10) : 0;
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
private deleteCookie(name: string): void {
|
||||
document.cookie = `${name}=; max-age=0; path=/; SameSite=Strict`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── File ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* File storage triggers a download on save() and opens a file picker
|
||||
* on load(). Because file-picker is inherently user-initiated, load()
|
||||
* here accepts a File object from an <input type="file">.
|
||||
*/
|
||||
export class FileStorage implements StorageAdapter {
|
||||
private pendingFile: File | null = null;
|
||||
|
||||
/** Call this from your file-input change handler before load() */
|
||||
setFile(f: File): void {
|
||||
this.pendingFile = f;
|
||||
}
|
||||
|
||||
async save(blob: string): Promise<void> {
|
||||
const data = new Blob([blob], { type: 'application/octet-stream' });
|
||||
const url = URL.createObjectURL(data);
|
||||
const a = document.createElement('a');
|
||||
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
||||
a.href = url;
|
||||
a.download = `ten99timecard-${ts}.t99`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async load(): Promise<string | null> {
|
||||
if (!this.pendingFile) return null;
|
||||
const text = await this.pendingFile.text();
|
||||
this.pendingFile = null;
|
||||
return text;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
/* No-op: we don't manage the user's filesystem */
|
||||
}
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function chunkString(s: string, size: number): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < s.length; i += size) out.push(s.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
103
src/lib/storage/vault.ts
Normal file
103
src/lib/storage/vault.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Vault — the bridge between app state, encryption, and storage adapters.
|
||||
*/
|
||||
|
||||
import type { AppData } from '@/types';
|
||||
import { encrypt, decrypt } from '@/lib/crypto/encryption';
|
||||
import { CookieStorage, FileStorage, type StorageAdapter } from './adapters';
|
||||
|
||||
// ─── .t99 file format ────────────────────────────────────────────────────────
|
||||
|
||||
type T99File =
|
||||
| { t99: 1; encrypted: false; data: AppData }
|
||||
| { t99: 1; encrypted: true; blob: string };
|
||||
|
||||
/** Returns true if the file content requires a password to import. */
|
||||
export function isT99Encrypted(content: string): boolean {
|
||||
try {
|
||||
const f = JSON.parse(content) as T99File;
|
||||
if (f.t99 === 1) return f.encrypted;
|
||||
} catch { /* fall through */ }
|
||||
// Legacy format: raw base64 encrypted blob (no JSON wrapper)
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Serialize AppData to .t99 file content, optionally encrypting it. */
|
||||
export async function serializeT99(data: AppData, password?: string): Promise<string> {
|
||||
if (password) {
|
||||
const blob = await encrypt(JSON.stringify(data), password);
|
||||
return JSON.stringify({ t99: 1, encrypted: true, blob } satisfies T99File);
|
||||
}
|
||||
return JSON.stringify({ t99: 1, encrypted: false, data } satisfies T99File);
|
||||
}
|
||||
|
||||
/** Parse .t99 file content back to AppData. Supply password only if encrypted. */
|
||||
export async function deserializeT99(content: string, password?: string): Promise<AppData> {
|
||||
try {
|
||||
const f = JSON.parse(content) as T99File;
|
||||
if (f.t99 === 1) {
|
||||
if (!f.encrypted) return f.data;
|
||||
if (!password) throw new Error('Password required to decrypt this file');
|
||||
return JSON.parse(await decrypt(f.blob, password));
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('Password required')) throw e;
|
||||
// Not our JSON wrapper — fall through to legacy format
|
||||
}
|
||||
// Legacy format: raw base64 encrypted blob
|
||||
if (!password) throw new Error('Password required to decrypt this file');
|
||||
return JSON.parse(await decrypt(content, password));
|
||||
}
|
||||
|
||||
// ─── Vault ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type VaultMode = 'cookie' | 'file';
|
||||
|
||||
export interface VaultConfig {
|
||||
mode: VaultMode;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class Vault {
|
||||
private adapter: StorageAdapter;
|
||||
private password: string;
|
||||
public readonly mode: VaultMode;
|
||||
/** File adapter is held separately so callers can set a pending file before load() */
|
||||
public readonly fileAdapter: FileStorage;
|
||||
|
||||
constructor(cfg: VaultConfig) {
|
||||
this.mode = cfg.mode;
|
||||
this.password = cfg.password;
|
||||
this.fileAdapter = new FileStorage();
|
||||
this.adapter = cfg.mode === 'file' ? this.fileAdapter : new CookieStorage(cfg.username);
|
||||
}
|
||||
|
||||
async save(data: AppData): Promise<void> {
|
||||
const blob = await encrypt(JSON.stringify(data), this.password);
|
||||
await this.adapter.save(blob);
|
||||
}
|
||||
|
||||
async load(): Promise<AppData | null> {
|
||||
const blob = await this.adapter.load();
|
||||
if (!blob) return null;
|
||||
const json = await decrypt(blob, this.password);
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await this.adapter.clear();
|
||||
}
|
||||
|
||||
/** Export current data as a .t99 file. Encrypts only if a password is given. */
|
||||
async exportToFile(data: AppData, password?: string): Promise<void> {
|
||||
const content = await serializeT99(data, password);
|
||||
await this.fileAdapter.save(content);
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a user already has encrypted data under their username (cookie mode) */
|
||||
export async function cookieDataExists(username: string): Promise<boolean> {
|
||||
const cs = new CookieStorage(username);
|
||||
return (await cs.load()) != null;
|
||||
}
|
||||
229
src/lib/tax/brackets.ts
Normal file
229
src/lib/tax/brackets.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* US Federal tax data — 2024 & 2025 tax years.
|
||||
* Sources: IRS Rev. Proc. 2023-34 (TY2024) and Rev. Proc. 2024-40 (TY2025).
|
||||
* All figures in USD.
|
||||
*/
|
||||
|
||||
import type { FilingStatus } from '@/types';
|
||||
|
||||
export interface Bracket {
|
||||
/** Upper bound of this bracket (Infinity for top) */
|
||||
upTo: number;
|
||||
/** Marginal rate as a decimal */
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export interface TaxYearData {
|
||||
year: number;
|
||||
brackets: Record<FilingStatus, Bracket[]>;
|
||||
standardDeduction: Record<FilingStatus, number>;
|
||||
/** Social Security wage base (SE tax applies 12.4% up to this) */
|
||||
ssWageBase: number;
|
||||
/** Additional Medicare Tax threshold */
|
||||
addlMedicareThreshold: Record<FilingStatus, number>;
|
||||
/** QBI (§199A) phase-out begins at this taxable income */
|
||||
qbiPhaseoutStart: Record<FilingStatus, number>;
|
||||
/** QBI phase-out range width */
|
||||
qbiPhaseoutRange: Record<FilingStatus, number>;
|
||||
/** Quarterly estimated payment due dates (ISO) */
|
||||
quarterlyDueDates: [string, string, string, string];
|
||||
}
|
||||
|
||||
// ─── 2024 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TY2024: TaxYearData = {
|
||||
year: 2024,
|
||||
brackets: {
|
||||
single: [
|
||||
{ upTo: 11600, rate: 0.10 },
|
||||
{ upTo: 47150, rate: 0.12 },
|
||||
{ upTo: 100525, rate: 0.22 },
|
||||
{ upTo: 191950, rate: 0.24 },
|
||||
{ upTo: 243725, rate: 0.32 },
|
||||
{ upTo: 609350, rate: 0.35 },
|
||||
{ upTo: Infinity, rate: 0.37 },
|
||||
],
|
||||
mfj: [
|
||||
{ upTo: 23200, rate: 0.10 },
|
||||
{ upTo: 94300, rate: 0.12 },
|
||||
{ upTo: 201050, rate: 0.22 },
|
||||
{ upTo: 383900, rate: 0.24 },
|
||||
{ upTo: 487450, rate: 0.32 },
|
||||
{ upTo: 731200, rate: 0.35 },
|
||||
{ upTo: Infinity, rate: 0.37 },
|
||||
],
|
||||
mfs: [
|
||||
{ upTo: 11600, rate: 0.10 },
|
||||
{ upTo: 47150, rate: 0.12 },
|
||||
{ upTo: 100525, rate: 0.22 },
|
||||
{ upTo: 191950, rate: 0.24 },
|
||||
{ upTo: 243725, rate: 0.32 },
|
||||
{ upTo: 365600, rate: 0.35 },
|
||||
{ upTo: Infinity, rate: 0.37 },
|
||||
],
|
||||
hoh: [
|
||||
{ upTo: 16550, rate: 0.10 },
|
||||
{ upTo: 63100, rate: 0.12 },
|
||||
{ upTo: 100500, rate: 0.22 },
|
||||
{ upTo: 191950, rate: 0.24 },
|
||||
{ upTo: 243700, rate: 0.32 },
|
||||
{ upTo: 609350, rate: 0.35 },
|
||||
{ upTo: Infinity, rate: 0.37 },
|
||||
],
|
||||
},
|
||||
standardDeduction: {
|
||||
single: 14600,
|
||||
mfj: 29200,
|
||||
mfs: 14600,
|
||||
hoh: 21900,
|
||||
},
|
||||
ssWageBase: 168600,
|
||||
addlMedicareThreshold: {
|
||||
single: 200000,
|
||||
mfj: 250000,
|
||||
mfs: 125000,
|
||||
hoh: 200000,
|
||||
},
|
||||
qbiPhaseoutStart: {
|
||||
single: 191950,
|
||||
mfj: 383900,
|
||||
mfs: 191950,
|
||||
hoh: 191950,
|
||||
},
|
||||
qbiPhaseoutRange: {
|
||||
single: 50000,
|
||||
mfj: 100000,
|
||||
mfs: 50000,
|
||||
hoh: 50000,
|
||||
},
|
||||
quarterlyDueDates: ['2024-04-15', '2024-06-17', '2024-09-16', '2025-01-15'],
|
||||
};
|
||||
|
||||
// ─── 2025 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TY2025: TaxYearData = {
|
||||
year: 2025,
|
||||
brackets: {
|
||||
single: [
|
||||
{ upTo: 11925, rate: 0.10 },
|
||||
{ upTo: 48475, rate: 0.12 },
|
||||
{ upTo: 103350, rate: 0.22 },
|
||||
{ upTo: 197300, rate: 0.24 },
|
||||
{ upTo: 250525, rate: 0.32 },
|
||||
{ upTo: 626350, rate: 0.35 },
|
||||
{ upTo: Infinity, rate: 0.37 },
|
||||
],
|
||||
mfj: [
|
||||
{ upTo: 23850, rate: 0.10 },
|
||||
{ upTo: 96950, rate: 0.12 },
|
||||
{ upTo: 206700, rate: 0.22 },
|
||||
{ upTo: 394600, rate: 0.24 },
|
||||
{ upTo: 501050, rate: 0.32 },
|
||||
{ upTo: 751600, rate: 0.35 },
|
||||
{ upTo: Infinity, rate: 0.37 },
|
||||
],
|
||||
mfs: [
|
||||
{ upTo: 11925, rate: 0.10 },
|
||||
{ upTo: 48475, rate: 0.12 },
|
||||
{ upTo: 103350, rate: 0.22 },
|
||||
{ upTo: 197300, rate: 0.24 },
|
||||
{ upTo: 250525, rate: 0.32 },
|
||||
{ upTo: 375800, rate: 0.35 },
|
||||
{ upTo: Infinity, rate: 0.37 },
|
||||
],
|
||||
hoh: [
|
||||
{ upTo: 17000, rate: 0.10 },
|
||||
{ upTo: 64850, rate: 0.12 },
|
||||
{ upTo: 103350, rate: 0.22 },
|
||||
{ upTo: 197300, rate: 0.24 },
|
||||
{ upTo: 250500, rate: 0.32 },
|
||||
{ upTo: 626350, rate: 0.35 },
|
||||
{ upTo: Infinity, rate: 0.37 },
|
||||
],
|
||||
},
|
||||
standardDeduction: {
|
||||
single: 15000,
|
||||
mfj: 30000,
|
||||
mfs: 15000,
|
||||
hoh: 22500,
|
||||
},
|
||||
ssWageBase: 176100,
|
||||
addlMedicareThreshold: {
|
||||
single: 200000,
|
||||
mfj: 250000,
|
||||
mfs: 125000,
|
||||
hoh: 200000,
|
||||
},
|
||||
qbiPhaseoutStart: {
|
||||
single: 197300,
|
||||
mfj: 394600,
|
||||
mfs: 197300,
|
||||
hoh: 197300,
|
||||
},
|
||||
qbiPhaseoutRange: {
|
||||
single: 50000,
|
||||
mfj: 100000,
|
||||
mfs: 50000,
|
||||
hoh: 50000,
|
||||
},
|
||||
quarterlyDueDates: ['2025-04-15', '2025-06-16', '2025-09-15', '2026-01-15'],
|
||||
};
|
||||
|
||||
// ─── Registry ────────────────────────────────────────────────────────────────
|
||||
|
||||
const YEARS: Record<number, TaxYearData> = {
|
||||
2024: TY2024,
|
||||
2025: TY2025,
|
||||
};
|
||||
|
||||
/** Advance a date past any weekend (Saturday→Monday, Sunday→Monday). */
|
||||
function nextWeekday(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
if (d.getDay() === 6) d.setDate(d.getDate() + 2); // Sat→Mon
|
||||
else if (d.getDay() === 0) d.setDate(d.getDate() + 1); // Sun→Mon
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Generate standard IRS quarterly estimated-payment due dates for a given tax year. */
|
||||
function quarterlyDueDatesForYear(year: number): [string, string, string, string] {
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
return [
|
||||
fmt(nextWeekday(new Date(year, 3, 15))), // Apr 15
|
||||
fmt(nextWeekday(new Date(year, 5, 15))), // Jun 15
|
||||
fmt(nextWeekday(new Date(year, 8, 15))), // Sep 15
|
||||
fmt(nextWeekday(new Date(year + 1, 0, 15))), // Jan 15 of following year
|
||||
];
|
||||
}
|
||||
|
||||
export function getTaxYearData(year: number): TaxYearData {
|
||||
const d = YEARS[year];
|
||||
if (d) return d;
|
||||
// Fall back to the closest known year so the app degrades gracefully,
|
||||
// but override quarterly due dates with correct dates for the requested year.
|
||||
const known = Object.keys(YEARS).map(Number).sort((a, b) => a - b);
|
||||
const closest = known.reduce((best, y) =>
|
||||
Math.abs(y - year) < Math.abs(best - year) ? y : best
|
||||
, known[0]);
|
||||
return { ...YEARS[closest], year, quarterlyDueDates: quarterlyDueDatesForYear(year) };
|
||||
}
|
||||
|
||||
export function availableTaxYears(): number[] {
|
||||
const defined = Object.keys(YEARS).map(Number);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const all = defined.includes(currentYear) ? defined : [...defined, currentYear];
|
||||
return all.sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/** Apply progressive brackets to a taxable income. */
|
||||
export function applyBrackets(brackets: Bracket[], taxable: number): number {
|
||||
if (taxable <= 0) return 0;
|
||||
let tax = 0;
|
||||
let lower = 0;
|
||||
for (const b of brackets) {
|
||||
const width = Math.min(taxable, b.upTo) - lower;
|
||||
if (width > 0) tax += width * b.rate;
|
||||
if (taxable <= b.upTo) break;
|
||||
lower = b.upTo;
|
||||
}
|
||||
return tax;
|
||||
}
|
||||
359
src/lib/tax/calculate.ts
Normal file
359
src/lib/tax/calculate.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
/**
|
||||
* 1099 Tax Calculation Engine
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Computes self-employment tax, federal income tax, quarterly estimates,
|
||||
* safe-harbor amounts, and produces UI prompts for any missing inputs
|
||||
* that would sharpen the calculation.
|
||||
*
|
||||
* This is NOT tax advice. It is a planning estimator.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Payment,
|
||||
Expense,
|
||||
TaxInputs,
|
||||
TaxResult,
|
||||
TaxPrompt,
|
||||
QuarterlyPayment,
|
||||
} from '@/types';
|
||||
import { getTaxYearData, applyBrackets } from './brackets';
|
||||
import type { TaxYearData } from './brackets';
|
||||
|
||||
// SE tax constants (these never change year-to-year)
|
||||
const SE_ADJUSTMENT = 0.9235; // 92.35% of net profit is subject to SE tax
|
||||
const SS_RATE = 0.124; // 12.4% Social Security (both halves)
|
||||
const MEDICARE_RATE = 0.029; // 2.9% Medicare (both halves)
|
||||
const ADDL_MEDICARE_RATE = 0.009; // 0.9% on high earners
|
||||
const QBI_RATE = 0.20; // 20% qualified business income deduction cap
|
||||
const SAFE_HARBOR_HIGH_AGI = 150000; // Threshold for 110% rule
|
||||
|
||||
interface CalculateOptions {
|
||||
/** Override "today" for deterministic testing & past-due flags */
|
||||
asOf?: Date;
|
||||
/** If true, project full-year income from YTD run rate */
|
||||
project?: boolean;
|
||||
}
|
||||
|
||||
export function calculateTax(
|
||||
payments: Payment[],
|
||||
expenses: Expense[],
|
||||
inputs: TaxInputs,
|
||||
opts: CalculateOptions = {},
|
||||
): TaxResult {
|
||||
const asOf = opts.asOf ?? new Date();
|
||||
const data = getTaxYearData(inputs.taxYear);
|
||||
const prompts: TaxPrompt[] = [];
|
||||
const notes: string[] = [];
|
||||
|
||||
// ─── 1. Schedule C: gross receipts & net profit ───────────────────────────
|
||||
|
||||
const yearPayments = payments.filter((p) => p.date.startsWith(String(inputs.taxYear)));
|
||||
const yearExpenses = expenses.filter((e) => e.date.startsWith(String(inputs.taxYear)));
|
||||
|
||||
let grossReceipts = sum(yearPayments.map((p) => p.amount));
|
||||
let deductibleExpenses = sum(
|
||||
yearExpenses.filter((e) => e.deductible).map((e) => e.amount),
|
||||
);
|
||||
|
||||
// Projection: scale YTD to full year based on fraction of year elapsed
|
||||
if (opts.project && isCurrentOrFutureYear(inputs.taxYear, asOf)) {
|
||||
const frac = fractionOfYearElapsed(inputs.taxYear, asOf);
|
||||
if (frac > 0 && frac < 1) {
|
||||
grossReceipts = grossReceipts / frac;
|
||||
deductibleExpenses = deductibleExpenses / frac;
|
||||
notes.push(
|
||||
`Projected from ${(frac * 100).toFixed(0)}% of year elapsed. ` +
|
||||
`Actual YTD receipts: $${sum(yearPayments.map((p) => p.amount)).toFixed(2)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const netProfit = Math.max(0, grossReceipts - deductibleExpenses);
|
||||
|
||||
// ─── 2. Self-Employment Tax (Schedule SE) ─────────────────────────────────
|
||||
|
||||
const seTaxableBase = netProfit * SE_ADJUSTMENT;
|
||||
|
||||
// Social Security portion capped at wage base (minus any W-2 wages already
|
||||
// subject to SS, since the cap is combined)
|
||||
const w2Wages = inputs.w2Wages ?? 0;
|
||||
const ssRoom = Math.max(0, data.ssWageBase - w2Wages);
|
||||
const ssTaxableAmount = Math.min(seTaxableBase, ssRoom);
|
||||
const socialSecurityTax = ssTaxableAmount * SS_RATE;
|
||||
|
||||
// Medicare — no cap
|
||||
const medicareTax = seTaxableBase * MEDICARE_RATE;
|
||||
|
||||
// Additional Medicare — 0.9% on combined wages+SE above threshold
|
||||
const addlMedThreshold = data.addlMedicareThreshold[inputs.filingStatus];
|
||||
const combinedMedicareWages = w2Wages + seTaxableBase;
|
||||
const addlMedBase = Math.max(0, combinedMedicareWages - addlMedThreshold);
|
||||
const additionalMedicareTax = addlMedBase * ADDL_MEDICARE_RATE;
|
||||
|
||||
const totalSETax = socialSecurityTax + medicareTax + additionalMedicareTax;
|
||||
|
||||
// ─── 3. Above-the-line deductions ─────────────────────────────────────────
|
||||
|
||||
// Half of SE tax is deductible from gross income
|
||||
const seTaxDeduction = (socialSecurityTax + medicareTax) * 0.5;
|
||||
|
||||
// AGI for our purposes
|
||||
const agi = Math.max(0, netProfit + w2Wages - seTaxDeduction);
|
||||
|
||||
// Standard deduction
|
||||
const standardDeduction = data.standardDeduction[inputs.filingStatus];
|
||||
|
||||
// Taxable income BEFORE QBI (we need this to figure out QBI phaseout)
|
||||
const preQbiTaxable = Math.max(0, agi - standardDeduction);
|
||||
|
||||
// QBI (§199A) — simplified: 20% of qualified business income, limited
|
||||
// to 20% of (taxable income - net capital gains [we assume 0]),
|
||||
// with phaseout for high earners.
|
||||
const qbiBase = netProfit - seTaxDeduction; // qualified business income
|
||||
let qbiDeduction = Math.min(qbiBase * QBI_RATE, preQbiTaxable * QBI_RATE);
|
||||
|
||||
const phaseStart = data.qbiPhaseoutStart[inputs.filingStatus];
|
||||
const phaseRange = data.qbiPhaseoutRange[inputs.filingStatus];
|
||||
if (preQbiTaxable > phaseStart) {
|
||||
// Simplified SSTB phaseout: linearly reduce to zero over the range.
|
||||
// (Real 199A is more complex; this is a reasonable estimator.)
|
||||
const excess = preQbiTaxable - phaseStart;
|
||||
const phaseoutFactor = Math.max(0, 1 - excess / phaseRange);
|
||||
qbiDeduction *= phaseoutFactor;
|
||||
if (phaseoutFactor < 1) {
|
||||
notes.push(`QBI deduction reduced by phaseout (${((1 - phaseoutFactor) * 100).toFixed(0)}% reduction).`);
|
||||
}
|
||||
}
|
||||
qbiDeduction = Math.max(0, qbiDeduction);
|
||||
|
||||
const taxableIncome = Math.max(0, preQbiTaxable - qbiDeduction);
|
||||
|
||||
// ─── 4. Federal income tax ────────────────────────────────────────────────
|
||||
|
||||
const federalIncomeTax = applyBrackets(
|
||||
data.brackets[inputs.filingStatus],
|
||||
taxableIncome,
|
||||
);
|
||||
|
||||
const totalFederalTax = federalIncomeTax + totalSETax;
|
||||
|
||||
// ─── 5. Payments already made ─────────────────────────────────────────────
|
||||
|
||||
const withholding = inputs.federalWithholding ?? 0;
|
||||
const taxPaymentsSum = (inputs.taxPayments ?? []).reduce((s, p) => s + p.amount, 0);
|
||||
const estimatedPaid = taxPaymentsSum + (inputs.estimatedPaymentsMade ?? 0);
|
||||
const alreadyPaid = withholding + estimatedPaid;
|
||||
const remainingDue = Math.max(0, totalFederalTax - alreadyPaid);
|
||||
|
||||
// ─── 6. Quarterly estimated payments & safe harbor ────────────────────────
|
||||
|
||||
// Estimated payments are only required if you expect to owe >= $1,000
|
||||
// after withholding.
|
||||
const owesEstimates = totalFederalTax - withholding >= 1000;
|
||||
if (!owesEstimates && totalFederalTax > 0) {
|
||||
notes.push('Expected to owe less than $1,000 after withholding — quarterly estimates may not be required.');
|
||||
}
|
||||
|
||||
// Safe harbor: you avoid underpayment penalty if you pay the LESSER of:
|
||||
// (a) 90% of current-year tax, OR
|
||||
// (b) 100% of prior-year tax (110% if prior-year AGI > $150k)
|
||||
// We need prior-year figures from the user to compute (b).
|
||||
let safeHarborAmount: number | null = null;
|
||||
let safeHarborMet: boolean | null = null;
|
||||
|
||||
if (inputs.priorYearTax == null) {
|
||||
prompts.push({
|
||||
field: 'priorYearTax',
|
||||
label: 'Previous year total federal tax',
|
||||
reason:
|
||||
'Lets us calculate the safe-harbor minimum — you avoid underpayment penalties if you pay at least this much, even if you underestimate this year.',
|
||||
severity: 'recommended',
|
||||
});
|
||||
}
|
||||
if (inputs.priorYearAGI == null) {
|
||||
prompts.push({
|
||||
field: 'priorYearAGI',
|
||||
label: 'Previous year adjusted gross income (AGI)',
|
||||
reason:
|
||||
'If your prior-year AGI was over $150,000, the safe harbor is 110% (not 100%) of last year\'s tax.',
|
||||
severity: 'recommended',
|
||||
});
|
||||
}
|
||||
|
||||
if (inputs.priorYearTax != null) {
|
||||
const highEarner = (inputs.priorYearAGI ?? 0) > SAFE_HARBOR_HIGH_AGI;
|
||||
const priorYearMultiplier = highEarner ? 1.10 : 1.00;
|
||||
const priorYearSafeHarbor = inputs.priorYearTax * priorYearMultiplier;
|
||||
const currentYearSafeHarbor = totalFederalTax * 0.90;
|
||||
safeHarborAmount = Math.min(priorYearSafeHarbor, currentYearSafeHarbor);
|
||||
safeHarborMet = alreadyPaid >= safeHarborAmount;
|
||||
|
||||
if (highEarner) {
|
||||
notes.push('Prior-year AGI > $150k: 110% safe-harbor rule applies.');
|
||||
}
|
||||
}
|
||||
|
||||
// Quarterly schedule — IRS annualized income installment method (Form 2210, Schedule AI)
|
||||
// Periods: Jan–Mar (×4, 22.5%), Jan–May (×2.4, 45%), Jan–Aug (×1.5, 67.5%), Jan–Dec (×1.0, 100%)
|
||||
const ANNUALIZED_PERIODS = [
|
||||
{ periodEnd: `${inputs.taxYear}-03-31`, factor: 4.0, cumPct: 0.225 },
|
||||
{ periodEnd: `${inputs.taxYear}-05-31`, factor: 2.4, cumPct: 0.450 },
|
||||
{ periodEnd: `${inputs.taxYear}-08-31`, factor: 1.5, cumPct: 0.675 },
|
||||
{ periodEnd: `${inputs.taxYear}-12-31`, factor: 1.0, cumPct: 1.000 },
|
||||
];
|
||||
|
||||
// Cumulative tax due through each quarter (based on annualized YTD income)
|
||||
const cumulativeDue = ANNUALIZED_PERIODS.map(({ periodEnd, factor, cumPct }) => {
|
||||
const periodGross =
|
||||
sum(yearPayments.filter((p) => p.date <= periodEnd).map((p) => p.amount)) * factor;
|
||||
const periodDeductible =
|
||||
sum(yearExpenses.filter((e) => e.date <= periodEnd && e.deductible).map((e) => e.amount)) * factor;
|
||||
const annualizedTax = computeAnnualizedTax(periodGross, periodDeductible, inputs, data);
|
||||
return round2(annualizedTax * cumPct);
|
||||
});
|
||||
|
||||
const safeHarborPerQuarter =
|
||||
safeHarborAmount != null ? Math.max(0, safeHarborAmount - withholding) / 4 : null;
|
||||
|
||||
const asOfISO = asOf.toISOString().slice(0, 10);
|
||||
const isPastDueFlags = data.quarterlyDueDates.map((d) => d < asOfISO);
|
||||
|
||||
const quarterlySchedule: QuarterlyPayment[] = data.quarterlyDueDates.map((dueDate, i) => {
|
||||
// Per-quarter installment = marginal increase in cumulative obligation
|
||||
const projectedAmount = round2(
|
||||
Math.max(0, cumulativeDue[i] - (i > 0 ? cumulativeDue[i - 1] : 0)),
|
||||
);
|
||||
// Payments credited through this quarter's due date
|
||||
const taxPaidBeforeDue = (inputs.taxPayments ?? [])
|
||||
.filter((p) => p.date <= dueDate)
|
||||
.reduce((s, p) => s + p.amount, 0);
|
||||
// Withholding and legacy estimated payments credited proportionally by cumulative %
|
||||
const withholdingCredit =
|
||||
(withholding + (inputs.estimatedPaymentsMade ?? 0)) * ANNUALIZED_PERIODS[i].cumPct;
|
||||
const remainingAmount = round2(
|
||||
Math.max(0, cumulativeDue[i] - taxPaidBeforeDue - withholdingCredit),
|
||||
);
|
||||
return {
|
||||
quarter: (i + 1) as 1 | 2 | 3 | 4,
|
||||
dueDate,
|
||||
projectedAmount,
|
||||
remainingAmount,
|
||||
safeHarborAmount: safeHarborPerQuarter != null ? round2(safeHarborPerQuarter) : null,
|
||||
isPastDue: isPastDueFlags[i],
|
||||
};
|
||||
});
|
||||
|
||||
// ─── 7. Additional prompts ────────────────────────────────────────────────
|
||||
|
||||
if (inputs.w2Wages == null) {
|
||||
prompts.push({
|
||||
field: 'w2Wages',
|
||||
label: 'W-2 wages (if any)',
|
||||
reason:
|
||||
'If you also have a W-2 job, those wages affect your tax bracket and the Social Security cap.',
|
||||
severity: 'recommended',
|
||||
});
|
||||
}
|
||||
|
||||
if (inputs.federalWithholding == null && (inputs.w2Wages ?? 0) > 0) {
|
||||
prompts.push({
|
||||
field: 'federalWithholding',
|
||||
label: 'Federal tax already withheld',
|
||||
reason: 'Withholding from your W-2 counts toward your estimated-payment obligation.',
|
||||
severity: 'required',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 8. Assemble ──────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
taxYear: inputs.taxYear,
|
||||
agi: round2(agi),
|
||||
grossReceipts: round2(grossReceipts),
|
||||
deductibleExpenses: round2(deductibleExpenses),
|
||||
netProfit: round2(netProfit),
|
||||
seTaxableBase: round2(seTaxableBase),
|
||||
socialSecurityTax: round2(socialSecurityTax),
|
||||
medicareTax: round2(medicareTax),
|
||||
additionalMedicareTax: round2(additionalMedicareTax),
|
||||
totalSETax: round2(totalSETax),
|
||||
seTaxDeduction: round2(seTaxDeduction),
|
||||
qbiDeduction: round2(qbiDeduction),
|
||||
standardDeduction,
|
||||
taxableIncome: round2(taxableIncome),
|
||||
federalIncomeTax: round2(federalIncomeTax),
|
||||
totalFederalTax: round2(totalFederalTax),
|
||||
alreadyPaid: round2(alreadyPaid),
|
||||
remainingDue: round2(remainingDue),
|
||||
quarterlySchedule,
|
||||
safeHarborAmount: safeHarborAmount != null ? round2(safeHarborAmount) : null,
|
||||
safeHarborMet,
|
||||
prompts,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function sum(xs: number[]): number {
|
||||
return xs.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function isCurrentOrFutureYear(year: number, asOf: Date): boolean {
|
||||
return year >= asOf.getFullYear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute total federal tax (SE + income) for a given gross/deductible pair.
|
||||
* Used by the annualized income installment method to compute per-period tax.
|
||||
*/
|
||||
function computeAnnualizedTax(
|
||||
grossReceipts: number,
|
||||
deductibleExpenses: number,
|
||||
inputs: TaxInputs,
|
||||
data: TaxYearData,
|
||||
): number {
|
||||
const netProfit = Math.max(0, grossReceipts - deductibleExpenses);
|
||||
const seTaxableBase = netProfit * SE_ADJUSTMENT;
|
||||
const w2Wages = inputs.w2Wages ?? 0;
|
||||
const ssRoom = Math.max(0, data.ssWageBase - w2Wages);
|
||||
const ssTaxableAmount = Math.min(seTaxableBase, ssRoom);
|
||||
const socialSecurityTax = ssTaxableAmount * SS_RATE;
|
||||
const medicareTax = seTaxableBase * MEDICARE_RATE;
|
||||
const addlMedBase = Math.max(
|
||||
0,
|
||||
w2Wages + seTaxableBase - data.addlMedicareThreshold[inputs.filingStatus],
|
||||
);
|
||||
const totalSETax = socialSecurityTax + medicareTax + addlMedBase * ADDL_MEDICARE_RATE;
|
||||
const seTaxDeduction = (socialSecurityTax + medicareTax) * 0.5;
|
||||
const agi = Math.max(0, netProfit + w2Wages - seTaxDeduction);
|
||||
const preQbiTaxable = Math.max(0, agi - data.standardDeduction[inputs.filingStatus]);
|
||||
const qbiBase = netProfit - seTaxDeduction;
|
||||
let qbiDeduction = Math.min(qbiBase * QBI_RATE, preQbiTaxable * QBI_RATE);
|
||||
const phaseStart = data.qbiPhaseoutStart[inputs.filingStatus];
|
||||
const phaseRange = data.qbiPhaseoutRange[inputs.filingStatus];
|
||||
if (preQbiTaxable > phaseStart) {
|
||||
qbiDeduction *= Math.max(0, 1 - (preQbiTaxable - phaseStart) / phaseRange);
|
||||
}
|
||||
const taxableIncome = Math.max(0, preQbiTaxable - Math.max(0, qbiDeduction));
|
||||
return totalSETax + applyBrackets(data.brackets[inputs.filingStatus], taxableIncome);
|
||||
}
|
||||
|
||||
function fractionOfYearElapsed(year: number, asOf: Date): number {
|
||||
if (asOf.getFullYear() < year) return 0;
|
||||
if (asOf.getFullYear() > year) return 1;
|
||||
// Use completed whole months for a smoother, less volatile projection.
|
||||
// asOf.getMonth() === 0 means we're in January (0 months complete).
|
||||
const completedMonths = asOf.getMonth();
|
||||
if (completedMonths === 0) {
|
||||
// In January: use day-fraction so we have at least some projection basis
|
||||
const start = new Date(year, 0, 1).getTime();
|
||||
const end = new Date(year + 1, 0, 1).getTime();
|
||||
return (asOf.getTime() - start) / (end - start);
|
||||
}
|
||||
return completedMonths / 12;
|
||||
}
|
||||
14
src/main.tsx
Normal file
14
src/main.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { applyTheme } from './themes/ThemeProvider';
|
||||
import './themes/global.css';
|
||||
|
||||
// Apply default theme immediately to avoid flash-of-unstyled-content
|
||||
applyTheme('standard', 'dark');
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
192
src/pages/DashboardPage.tsx
Normal file
192
src/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* Dashboard — configurable at-a-glance view.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
import { aggregate } from '@/lib/stats/aggregate';
|
||||
import { calculateTax } from '@/lib/tax/calculate';
|
||||
import { fmtMoney, fmtMoneyShort } from '@/lib/format';
|
||||
import { ChartPanel } from '@/components/charts/ChartPanel';
|
||||
import { Modal } from '@/components/common/Modal';
|
||||
import type { DashboardWidget } from '@/types';
|
||||
|
||||
const WIDGET_LABELS: Record<DashboardWidget, string> = {
|
||||
ytdWorkValue: 'YTD Work Value',
|
||||
ytdWorkProj: 'Work Value Projected',
|
||||
ytdPayments: 'YTD Payments',
|
||||
ytdPaymentsProj: 'Payments Projected',
|
||||
ytdExpenses: 'YTD Expenses',
|
||||
ytdNet: 'YTD Net Income',
|
||||
ytdNetProj: 'Net Income Projected',
|
||||
nextQuarterlyDue: 'Next Quarterly Due',
|
||||
projectedAnnualTax: 'Projected Annual Tax',
|
||||
ytdActualTax: 'YTD Actual Tax',
|
||||
taxRemainingDue: 'Tax Remaining Due',
|
||||
avgMonthlyNet: 'Avg Monthly Net',
|
||||
avgDailyWork: 'Avg Daily Work',
|
||||
};
|
||||
|
||||
export function DashboardPage() {
|
||||
const data = useAppStore((s) => s.data);
|
||||
const addChart = useAppStore((s) => s.addChart);
|
||||
const updateChart = useAppStore((s) => s.updateChart);
|
||||
const removeChart = useAppStore((s) => s.removeChart);
|
||||
const setWidgets = useAppStore((s) => s.setDashboardWidgets);
|
||||
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
|
||||
const stats = useMemo(
|
||||
() => aggregate(data.workEntries, data.payments, data.expenses),
|
||||
[data.workEntries, data.payments, data.expenses],
|
||||
);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentYearStats = stats.years.find((y) => y.label === String(currentYear));
|
||||
|
||||
const taxInputs = data.taxInputs[currentYear] ?? { taxYear: currentYear, filingStatus: 'single' as const };
|
||||
// Auto-populate prior year from stored data if not manually entered
|
||||
const prevYearInputs = data.taxInputs[currentYear - 1];
|
||||
const prevYearResult = useMemo(
|
||||
() => prevYearInputs ? calculateTax(data.payments, data.expenses, prevYearInputs, { project: false }) : null,
|
||||
[data.payments, data.expenses, prevYearInputs],
|
||||
);
|
||||
const effectiveTaxInputs = useMemo(() => ({
|
||||
...taxInputs,
|
||||
priorYearTax: taxInputs.priorYearTax ?? prevYearResult?.totalFederalTax,
|
||||
priorYearAGI: taxInputs.priorYearAGI ?? prevYearResult?.agi,
|
||||
}), [taxInputs, prevYearResult]);
|
||||
const taxResult = useMemo(
|
||||
() => calculateTax(data.payments, data.expenses, effectiveTaxInputs, { project: true }),
|
||||
[data.payments, data.expenses, effectiveTaxInputs],
|
||||
);
|
||||
const ytdTaxResult = useMemo(
|
||||
() => calculateTax(data.payments, data.expenses, effectiveTaxInputs, { project: false }),
|
||||
[data.payments, data.expenses, effectiveTaxInputs],
|
||||
);
|
||||
|
||||
const nextQuarter = taxResult.quarterlySchedule.find((q) => !q.isPastDue);
|
||||
|
||||
// Year projection fraction (day-based for work/payment/expense projections)
|
||||
const now = new Date();
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1).getTime();
|
||||
const yearEnd = new Date(now.getFullYear() + 1, 0, 1).getTime();
|
||||
const yearFrac = (now.getTime() - yearStart) / (yearEnd - yearStart);
|
||||
const proj = (v: number) => (v > 0 && yearFrac > 0 && yearFrac < 1) ? v / yearFrac : null;
|
||||
|
||||
const ytdWork = currentYearStats?.workValue ?? 0;
|
||||
const ytdPayments = currentYearStats?.payments ?? 0;
|
||||
const ytdExpenses = currentYearStats?.expenses ?? 0;
|
||||
const ytdNet = currentYearStats?.net ?? 0;
|
||||
|
||||
const widgets: Record<DashboardWidget, { value: string; sub?: string; className?: string }> = {
|
||||
ytdWorkValue: { value: fmtMoneyShort(ytdWork) },
|
||||
ytdWorkProj: { value: fmtMoneyShort(proj(ytdWork)), sub: 'full year est.' },
|
||||
ytdPayments: { value: fmtMoneyShort(ytdPayments) },
|
||||
ytdPaymentsProj: { value: fmtMoneyShort(proj(ytdPayments)), sub: 'full year est.' },
|
||||
ytdExpenses: { value: fmtMoneyShort(ytdExpenses), className: 'negative' },
|
||||
ytdNet: {
|
||||
value: fmtMoneyShort(ytdNet),
|
||||
className: ytdNet >= 0 ? 'positive' : 'negative',
|
||||
},
|
||||
ytdNetProj: { value: fmtMoneyShort(proj(ytdNet)), sub: 'full year est.' },
|
||||
nextQuarterlyDue: {
|
||||
value: nextQuarter ? fmtMoneyShort(nextQuarter.remainingAmount) : '—',
|
||||
sub: nextQuarter ? `Q${nextQuarter.quarter} due ${nextQuarter.dueDate}` : 'All paid',
|
||||
},
|
||||
projectedAnnualTax: {
|
||||
value: fmtMoneyShort(taxResult.totalFederalTax),
|
||||
sub: 'full year est.',
|
||||
className: 'negative',
|
||||
},
|
||||
ytdActualTax: {
|
||||
value: fmtMoneyShort(ytdTaxResult.totalFederalTax),
|
||||
sub: 'based on actual YTD',
|
||||
className: 'negative',
|
||||
},
|
||||
taxRemainingDue: {
|
||||
value: fmtMoneyShort(taxResult.remainingDue),
|
||||
sub: 'after payments made',
|
||||
className: 'negative',
|
||||
},
|
||||
avgMonthlyNet: {
|
||||
value: fmtMoneyShort(currentYearStats?.avgPerChild ?? 0),
|
||||
sub: `${currentYearStats?.childCount ?? 0} months`,
|
||||
},
|
||||
avgDailyWork: {
|
||||
value: fmtMoneyShort(
|
||||
[...stats.days.values()].reduce((s, d) => s + d.workValue, 0) /
|
||||
Math.max(1, stats.days.size),
|
||||
),
|
||||
sub: `${stats.days.size} days logged`,
|
||||
},
|
||||
};
|
||||
|
||||
const toggleWidget = (w: DashboardWidget) => {
|
||||
const next = data.dashboard.widgets.includes(w)
|
||||
? data.dashboard.widgets.filter((x) => x !== w)
|
||||
: [...data.dashboard.widgets, w];
|
||||
setWidgets(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1>Dashboard</h1>
|
||||
<button className="btn btn-sm" onClick={() => setConfigOpen(true)}>
|
||||
⚙ Configure
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stat widgets */}
|
||||
<div className="stat-grid">
|
||||
{data.dashboard.widgets.map((w) => {
|
||||
const def = widgets[w];
|
||||
return (
|
||||
<div key={w} className={`stat-card ${def.className ?? ''}`}>
|
||||
<div className="stat-label">{WIDGET_LABELS[w]}</div>
|
||||
<div className="stat-value">{def.value}</div>
|
||||
{def.sub && <div className="stat-sub">{def.sub}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="chart-grid">
|
||||
{data.dashboard.charts.map((c) => (
|
||||
<ChartPanel
|
||||
key={c.id}
|
||||
config={c}
|
||||
onChange={(patch) => updateChart(c.id, patch)}
|
||||
onRemove={data.dashboard.charts.length > 1 ? () => removeChart(c.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="btn" onClick={() => addChart()}>+ Add chart</button>
|
||||
|
||||
{/* Config modal */}
|
||||
<Modal
|
||||
open={configOpen}
|
||||
title="Configure dashboard"
|
||||
onClose={() => setConfigOpen(false)}
|
||||
footer={<button className="btn btn-primary" onClick={() => setConfigOpen(false)}>Done</button>}
|
||||
>
|
||||
<div className="flex-col gap-2">
|
||||
<p className="text-sm text-muted">Choose which stats appear at the top:</p>
|
||||
{(Object.keys(WIDGET_LABELS) as DashboardWidget[]).map((w) => (
|
||||
<label key={w} className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.dashboard.widgets.includes(w)}
|
||||
onChange={() => toggleWidget(w)}
|
||||
/>
|
||||
{WIDGET_LABELS[w]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
721
src/pages/LedgerPage.tsx
Normal file
721
src/pages/LedgerPage.tsx
Normal file
|
|
@ -0,0 +1,721 @@
|
|||
/**
|
||||
* Shared Ledger page — tabs for Work Log, Payments, Expenses.
|
||||
* Left: hierarchical spreadsheet + entry form + summary stats.
|
||||
* Right: configurable charts.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
import type { WorkEntry, Payment, Expense, RecurringExpense, RecurringFrequency, LedgerTile } from '@/types';
|
||||
import { workEntryValue } from '@/types';
|
||||
import { HierSpreadsheet } from '@/components/spreadsheet/HierSpreadsheet';
|
||||
import { WorkEntryForm, PaymentForm, ExpenseForm } from '@/components/spreadsheet/EntryForm';
|
||||
import { ChartSidebar } from '@/components/charts/ChartSidebar';
|
||||
import { ResizableSplit } from '@/components/layout/ResizableSplit';
|
||||
import { Modal, ConfirmDialog } from '@/components/common/Modal';
|
||||
import { buildHierarchyForRange, buildHierarchy, aggregate } from '@/lib/stats/aggregate';
|
||||
import { fmtMoney, todayISO } from '@/lib/format';
|
||||
|
||||
function yearStart() { return `${new Date().getFullYear()}-01-01`; }
|
||||
|
||||
/** Sort unique string values by frequency (desc), with the most-recently-used value first. */
|
||||
function freqSorted(values: Array<{ value: string; createdAt: number }>): string[] {
|
||||
const freq = new Map<string, number>();
|
||||
let mruValue = '';
|
||||
let mruTime = 0;
|
||||
for (const { value, createdAt } of values) {
|
||||
if (!value) continue;
|
||||
freq.set(value, (freq.get(value) ?? 0) + 1);
|
||||
if (createdAt > mruTime) { mruTime = createdAt; mruValue = value; }
|
||||
}
|
||||
const sorted = [...freq.keys()].sort((a, b) => (freq.get(b) ?? 0) - (freq.get(a) ?? 0));
|
||||
if (mruValue) {
|
||||
const i = sorted.indexOf(mruValue);
|
||||
if (i > 0) { sorted.splice(i, 1); sorted.unshift(mruValue); }
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function usePersistedDate(key: string): [string, (v: string) => void] {
|
||||
const [value, setValue] = useState(() => localStorage.getItem(key) ?? yearStart());
|
||||
const set = (v: string) => { setValue(v); localStorage.setItem(key, v); };
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
type Tab = 'work' | 'payments' | 'expenses';
|
||||
|
||||
export function LedgerPage({ initialTab = 'work' }: { initialTab?: Tab }) {
|
||||
const [workStart, setWorkStart] = usePersistedDate('ledger_work_from');
|
||||
const [paymentsStart, setPaymentsStart] = usePersistedDate('ledger_payments_from');
|
||||
const [expensesStart, setExpensesStart] = usePersistedDate('ledger_expenses_from');
|
||||
const today = todayISO();
|
||||
|
||||
const startDate =
|
||||
initialTab === 'work' ? workStart :
|
||||
initialTab === 'payments' ? paymentsStart :
|
||||
expensesStart;
|
||||
|
||||
return (
|
||||
<ResizableSplit
|
||||
left={
|
||||
<>
|
||||
{initialTab === 'work' && <WorkTab startDate={workStart} setStartDate={setWorkStart} />}
|
||||
{initialTab === 'payments' && <PaymentsTab startDate={paymentsStart} setStartDate={setPaymentsStart} />}
|
||||
{initialTab === 'expenses' && <ExpensesTab startDate={expensesStart} setStartDate={setExpensesStart} />}
|
||||
</>
|
||||
}
|
||||
right={<ChartSidebar tab={initialTab} defaultRangeStart={startDate} defaultRangeEnd={today} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Work Tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function WorkTab({ startDate, setStartDate }: { startDate: string; setStartDate: (v: string) => void }) {
|
||||
const entries = useAppStore((s) => s.data.workEntries);
|
||||
const defaultRate = useAppStore((s) => s.data.settings.defaultRate);
|
||||
const addWorkEntry = useAppStore((s) => s.addWorkEntry);
|
||||
const updateWorkEntry = useAppStore((s) => s.updateWorkEntry);
|
||||
const deleteWorkEntry = useAppStore((s) => s.deleteWorkEntry);
|
||||
const tiles = useAppStore((s) => s.data.dashboard.workTiles);
|
||||
const setPageTiles = useAppStore((s) => s.setPageTiles);
|
||||
|
||||
const [addForDay, setAddForDay] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<WorkEntry | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
|
||||
const today = todayISO();
|
||||
|
||||
const nodes = useMemo(
|
||||
() =>
|
||||
buildHierarchyForRange(
|
||||
entries,
|
||||
startDate,
|
||||
today,
|
||||
(e) => workEntryValue(e as WorkEntry),
|
||||
(e) => {
|
||||
const w = e as WorkEntry;
|
||||
const extra = w.hours != null ? ` (${w.hours}h @ $${w.rate})` : '';
|
||||
return `${w.description}${extra}${w.client ? ` — ${w.client}` : ''}`;
|
||||
},
|
||||
),
|
||||
[entries, startDate, today],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => aggregate(entries, [], []), [entries]);
|
||||
|
||||
const existingClients = useMemo(
|
||||
() => freqSorted(entries.map((e) => ({ value: e.client ?? '', createdAt: e.createdAt }))),
|
||||
[entries],
|
||||
);
|
||||
const existingDescriptions = useMemo(
|
||||
() => freqSorted(entries.map((e) => ({ value: e.description, createdAt: e.createdAt }))),
|
||||
[entries],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PeriodSummaryRow
|
||||
stats={stats}
|
||||
metric="workValue"
|
||||
label="Work value"
|
||||
tiles={tiles}
|
||||
onConfigure={() => setConfigOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="card-header">
|
||||
<span className="card-title">Work Log</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted">From</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
style={{ width: 148, height: 32 }}
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setAddForDay(today)}>
|
||||
+ Add entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<HierSpreadsheet
|
||||
nodes={nodes}
|
||||
valueLabel="Earned"
|
||||
onAddForDay={(date) => setAddForDay(date)}
|
||||
onView={(n) => setEditing(n.entry as WorkEntry)}
|
||||
onEdit={(n) => setEditing(n.entry as WorkEntry)}
|
||||
onDelete={(n) => setDeleting(n.entry!.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal open={addForDay != null} title="Add work entry" onClose={() => setAddForDay(null)}>
|
||||
{addForDay != null && (
|
||||
<WorkEntryForm
|
||||
initial={{ date: addForDay }}
|
||||
defaultRate={defaultRate}
|
||||
existingClients={existingClients}
|
||||
existingDescriptions={existingDescriptions}
|
||||
onSubmit={(d) => { addWorkEntry(d); setAddForDay(null); }}
|
||||
onCancel={() => setAddForDay(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal open={editing != null} title="Edit work entry" onClose={() => setEditing(null)}>
|
||||
{editing && (
|
||||
<WorkEntryForm
|
||||
initial={editing}
|
||||
defaultRate={defaultRate}
|
||||
existingClients={existingClients}
|
||||
existingDescriptions={existingDescriptions}
|
||||
onSubmit={(d) => { updateWorkEntry(editing.id, d); setEditing(null); }}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting != null}
|
||||
title="Delete work entry?"
|
||||
message="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={() => { if (deleting) deleteWorkEntry(deleting); setDeleting(null); }}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
|
||||
<TileConfigModal
|
||||
open={configOpen}
|
||||
tiles={tiles}
|
||||
onChange={(t) => setPageTiles('work', t)}
|
||||
onClose={() => setConfigOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Payments Tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function PaymentsTab({ startDate, setStartDate }: { startDate: string; setStartDate: (v: string) => void }) {
|
||||
const payments = useAppStore((s) => s.data.payments);
|
||||
const addPayment = useAppStore((s) => s.addPayment);
|
||||
const updatePayment = useAppStore((s) => s.updatePayment);
|
||||
const deletePayment = useAppStore((s) => s.deletePayment);
|
||||
const tiles = useAppStore((s) => s.data.dashboard.paymentsTiles);
|
||||
const setPageTiles = useAppStore((s) => s.setPageTiles);
|
||||
|
||||
const [addForDay, setAddForDay] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Payment | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
|
||||
const today = todayISO();
|
||||
|
||||
const nodes = useMemo(
|
||||
() =>
|
||||
buildHierarchyForRange(
|
||||
payments,
|
||||
startDate,
|
||||
today,
|
||||
(e) => (e as Payment).amount,
|
||||
(e) => {
|
||||
const p = e as Payment;
|
||||
return `${p.payer} (${p.form ?? 'direct'})${p.notes ? ` — ${p.notes}` : ''}`;
|
||||
},
|
||||
),
|
||||
[payments, startDate, today],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => aggregate([], payments, []), [payments]);
|
||||
|
||||
const recentForm = useMemo(
|
||||
() => payments.slice().sort((a, b) => b.createdAt - a.createdAt)[0]?.form,
|
||||
[payments],
|
||||
);
|
||||
|
||||
const existingPayers = useMemo(
|
||||
() => freqSorted(payments.map((p) => ({ value: p.payer, createdAt: p.createdAt }))),
|
||||
[payments],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PeriodSummaryRow
|
||||
stats={stats}
|
||||
metric="payments"
|
||||
label="Taxable income"
|
||||
tiles={tiles}
|
||||
onConfigure={() => setConfigOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="card-header">
|
||||
<span className="card-title">Payments Received</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted">From</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
style={{ width: 148, height: 32 }}
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setAddForDay(today)}>
|
||||
+ Add payment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<HierSpreadsheet
|
||||
nodes={nodes}
|
||||
valueLabel="Amount"
|
||||
onAddForDay={(date) => setAddForDay(date)}
|
||||
onView={(n) => setEditing(n.entry as Payment)}
|
||||
onEdit={(n) => setEditing(n.entry as Payment)}
|
||||
onDelete={(n) => setDeleting(n.entry!.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal open={addForDay != null} title="Add payment" onClose={() => setAddForDay(null)}>
|
||||
{addForDay != null && (
|
||||
<PaymentForm
|
||||
initial={{ date: addForDay }}
|
||||
defaultForm={recentForm}
|
||||
existingPayers={existingPayers}
|
||||
onSubmit={(d) => { addPayment(d); setAddForDay(null); }}
|
||||
onCancel={() => setAddForDay(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal open={editing != null} title="Edit payment" onClose={() => setEditing(null)}>
|
||||
{editing && (
|
||||
<PaymentForm
|
||||
initial={editing}
|
||||
existingPayers={existingPayers}
|
||||
onSubmit={(d) => { updatePayment(editing.id, d); setEditing(null); }}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting != null}
|
||||
title="Delete payment?"
|
||||
message="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={() => { if (deleting) deletePayment(deleting); setDeleting(null); }}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
|
||||
<TileConfigModal
|
||||
open={configOpen}
|
||||
tiles={tiles}
|
||||
onChange={(t) => setPageTiles('payments', t)}
|
||||
onClose={() => setConfigOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Expenses Tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function ExpensesTab({ startDate, setStartDate }: { startDate: string; setStartDate: (v: string) => void }) {
|
||||
const expenses = useAppStore((s) => s.data.expenses);
|
||||
const addExpense = useAppStore((s) => s.addExpense);
|
||||
const updateExpense = useAppStore((s) => s.updateExpense);
|
||||
const deleteExpense = useAppStore((s) => s.deleteExpense);
|
||||
const recurringExpenses = useAppStore((s) => s.data.recurringExpenses);
|
||||
const addRecurringExpense = useAppStore((s) => s.addRecurringExpense);
|
||||
const updateRecurringExpense = useAppStore((s) => s.updateRecurringExpense);
|
||||
const deleteRecurringExpense = useAppStore((s) => s.deleteRecurringExpense);
|
||||
|
||||
const tiles = useAppStore((s) => s.data.dashboard.expensesTiles);
|
||||
const setPageTiles = useAppStore((s) => s.setPageTiles);
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [editing, setEditing] = useState<Expense | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [showRecurring, setShowRecurring] = useState(false);
|
||||
const [editingRecurring, setEditingRecurring] = useState<RecurringExpense | null>(null);
|
||||
const [deletingRecurring, setDeletingRecurring] = useState<string | null>(null);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
|
||||
const today = todayISO();
|
||||
|
||||
const nodes = useMemo(
|
||||
() =>
|
||||
buildHierarchyForRange(
|
||||
expenses,
|
||||
startDate,
|
||||
today,
|
||||
(e) => (e as Expense).amount,
|
||||
(e) => {
|
||||
const x = e as Expense;
|
||||
return `${x.description}${x.deductible ? ' ✓ deductible' : ''}${x.category ? ` — ${x.category}` : ''}`;
|
||||
},
|
||||
),
|
||||
[expenses, startDate, today],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => aggregate([], [], expenses), [expenses]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PeriodSummaryRow
|
||||
stats={stats}
|
||||
metric="expenses"
|
||||
label="Expenses"
|
||||
tiles={tiles}
|
||||
onConfigure={() => setConfigOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="card-header">
|
||||
<span className="card-title">Expenses</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted">From</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
style={{ width: 148, height: 32 }}
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setShowAdd(true)}>
|
||||
+ Add expense
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<HierSpreadsheet
|
||||
nodes={nodes}
|
||||
valueLabel="Amount"
|
||||
onView={(n) => setEditing(n.entry as Expense)}
|
||||
onEdit={(n) => setEditing(n.entry as Expense)}
|
||||
onDelete={(n) => setDeleting(n.entry!.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal open={showAdd} title="Add expense" onClose={() => setShowAdd(false)}>
|
||||
<ExpenseForm onSubmit={(d) => { addExpense(d); setShowAdd(false); }} onCancel={() => setShowAdd(false)} />
|
||||
</Modal>
|
||||
|
||||
<Modal open={editing != null} title="Edit expense" onClose={() => setEditing(null)}>
|
||||
{editing && (
|
||||
<ExpenseForm
|
||||
initial={editing}
|
||||
onSubmit={(d) => { updateExpense(editing.id, d); setEditing(null); }}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting != null}
|
||||
title="Delete expense?"
|
||||
message="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={() => { if (deleting) deleteExpense(deleting); setDeleting(null); }}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
|
||||
{/* ─── Recurring expenses ─────────────────────────── */}
|
||||
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="card-header">
|
||||
<span className="card-title">Recurring Expenses</span>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setShowRecurring(true)}>
|
||||
+ Add recurring
|
||||
</button>
|
||||
</div>
|
||||
{recurringExpenses.length === 0 ? (
|
||||
<div className="text-muted text-sm" style={{ padding: '12px 16px' }}>
|
||||
No recurring expenses set up. Add one to auto-generate expense entries.
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th className="num">Amount</th>
|
||||
<th>Frequency</th>
|
||||
<th>Since</th>
|
||||
<th>Category</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recurringExpenses.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.description}{r.deductible ? ' ✓' : ''}</td>
|
||||
<td className="num">${r.amount.toFixed(2)}</td>
|
||||
<td className="text-sm">{r.frequency}</td>
|
||||
<td className="text-sm text-muted">{r.startDate}</td>
|
||||
<td className="text-sm text-muted">{r.category ?? '—'}</td>
|
||||
<td>
|
||||
<div className="flex gap-1">
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => setEditingRecurring(r)} title="Edit">✎</button>
|
||||
<button className="btn btn-sm btn-ghost text-danger" onClick={() => setDeletingRecurring(r.id)} title="Delete">✕</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal open={showRecurring} title="Add recurring expense" onClose={() => setShowRecurring(false)}>
|
||||
<RecurringExpenseForm
|
||||
onSubmit={(d) => { addRecurringExpense(d); setShowRecurring(false); }}
|
||||
onCancel={() => setShowRecurring(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal open={editingRecurring != null} title="Edit recurring expense" onClose={() => setEditingRecurring(null)}>
|
||||
{editingRecurring && (
|
||||
<RecurringExpenseForm
|
||||
initial={editingRecurring}
|
||||
onSubmit={(d) => { updateRecurringExpense(editingRecurring.id, d); setEditingRecurring(null); }}
|
||||
onCancel={() => setEditingRecurring(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deletingRecurring != null}
|
||||
title="Delete recurring expense?"
|
||||
message="This removes the template but keeps existing expense entries already created from it."
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={() => { if (deletingRecurring) deleteRecurringExpense(deletingRecurring); setDeletingRecurring(null); }}
|
||||
onCancel={() => setDeletingRecurring(null)}
|
||||
/>
|
||||
|
||||
<TileConfigModal
|
||||
open={configOpen}
|
||||
tiles={tiles}
|
||||
onChange={(t) => setPageTiles('expenses', t)}
|
||||
onClose={() => setConfigOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Recurring expense form ───────────────────────────────────────────────────
|
||||
|
||||
type RecurringDraft = Omit<RecurringExpense, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
const FREQ_LABELS: Record<RecurringFrequency, string> = {
|
||||
weekly: 'Weekly', biweekly: 'Bi-weekly', monthly: 'Monthly',
|
||||
quarterly: 'Quarterly', annually: 'Annually',
|
||||
};
|
||||
|
||||
function RecurringExpenseForm({
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: Partial<RecurringDraft>;
|
||||
onSubmit: (d: RecurringDraft) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [desc, setDesc] = useState(initial?.description ?? '');
|
||||
const [amount, setAmount] = useState(initial?.amount?.toString() ?? '');
|
||||
const [category, setCategory] = useState(initial?.category ?? '');
|
||||
const [deductible, setDeductible] = useState(initial?.deductible ?? true);
|
||||
const [frequency, setFrequency] = useState<RecurringFrequency>(initial?.frequency ?? 'monthly');
|
||||
const [dayOfMonth, setDayOfMonth] = useState(initial?.dayOfMonth?.toString() ?? '1');
|
||||
const [startDate, setStartDate] = useState(initial?.startDate ?? todayISO());
|
||||
const [endDate, setEndDate] = useState(initial?.endDate ?? '');
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
description: desc, amount: parseFloat(amount) || 0,
|
||||
category: category || undefined, deductible, frequency,
|
||||
dayOfMonth: parseInt(dayOfMonth) || 1,
|
||||
startDate, endDate: endDate || null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex-col gap-3">
|
||||
<div className="field">
|
||||
<label>Description</label>
|
||||
<input className="input" value={desc} onChange={(e) => setDesc(e.target.value)} required placeholder="e.g. Rent, Software subscription" />
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Amount ($)</label>
|
||||
<input type="number" step="0.01" className="input" value={amount} onChange={(e) => setAmount(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Frequency</label>
|
||||
<select className="select" value={frequency} onChange={(e) => setFrequency(e.target.value as RecurringFrequency)}>
|
||||
{(Object.keys(FREQ_LABELS) as RecurringFrequency[]).map((f) => (
|
||||
<option key={f} value={f}>{FREQ_LABELS[f]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{(frequency === 'monthly' || frequency === 'quarterly' || frequency === 'annually') && (
|
||||
<div className="field">
|
||||
<label>Day of month</label>
|
||||
<input type="number" className="input" min={1} max={28} value={dayOfMonth} onChange={(e) => setDayOfMonth(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Start date</label>
|
||||
<input type="date" className="input" value={startDate} onChange={(e) => setStartDate(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>End date (optional)</label>
|
||||
<input type="date" className="input" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Category</label>
|
||||
<input className="input" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="optional" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label> </label>
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={deductible} onChange={(e) => setDeductible(e.target.checked)} />
|
||||
Tax deductible
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={onCancel}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Period summary strip (configurable tiles) ───────────────────────────────
|
||||
|
||||
const LEDGER_TILE_LABELS: Record<LedgerTile, string> = {
|
||||
ytd: 'YTD total',
|
||||
avgMonth: 'Avg / month',
|
||||
yearProj: 'Year projected',
|
||||
thisMonth: 'This month',
|
||||
avgDay: 'Avg / day',
|
||||
monthProj: 'Month projected',
|
||||
today: 'Today',
|
||||
};
|
||||
|
||||
const ALL_LEDGER_TILES: LedgerTile[] = ['ytd', 'avgMonth', 'yearProj', 'thisMonth', 'avgDay', 'monthProj', 'today'];
|
||||
|
||||
function PeriodSummaryRow({
|
||||
stats,
|
||||
metric,
|
||||
label,
|
||||
tiles,
|
||||
onConfigure,
|
||||
}: {
|
||||
stats: ReturnType<typeof aggregate>;
|
||||
metric: 'workValue' | 'payments' | 'expenses';
|
||||
label: string;
|
||||
tiles: LedgerTile[];
|
||||
onConfigure: () => void;
|
||||
}) {
|
||||
const now = new Date();
|
||||
const currentYear = String(now.getFullYear());
|
||||
const currentMonth = now.toISOString().slice(0, 7);
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
|
||||
const y = stats.years.find((x) => x.label === currentYear);
|
||||
const m = stats.months.get(currentMonth);
|
||||
const d = stats.days.get(today);
|
||||
|
||||
const yValue = y?.[metric] ?? 0;
|
||||
const mValue = m?.[metric] ?? 0;
|
||||
|
||||
const monthsElapsed = now.getMonth() + 1;
|
||||
const dayOfMonth = now.getDate();
|
||||
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1).getTime();
|
||||
const yearEnd = new Date(now.getFullYear() + 1, 0, 1).getTime();
|
||||
const yearFrac = (now.getTime() - yearStart) / (yearEnd - yearStart);
|
||||
|
||||
const tileValues: Record<LedgerTile, number | null> = {
|
||||
ytd: yValue,
|
||||
avgMonth: yValue > 0 ? yValue / monthsElapsed : null,
|
||||
yearProj: yValue > 0 && yearFrac > 0 && yearFrac < 1 ? yValue / yearFrac : null,
|
||||
thisMonth: mValue,
|
||||
avgDay: mValue > 0 ? mValue / dayOfMonth : null,
|
||||
monthProj: mValue > 0 && dayOfMonth > 0 && dayOfMonth < daysInMonth
|
||||
? (mValue / dayOfMonth) * daysInMonth
|
||||
: null,
|
||||
today: d?.[metric] ?? 0,
|
||||
};
|
||||
|
||||
const tileDisplayLabel: Record<LedgerTile, string> = {
|
||||
...LEDGER_TILE_LABELS,
|
||||
ytd: `YTD ${label}`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 4 }}>
|
||||
<button className="btn btn-sm btn-ghost" onClick={onConfigure}>⚙ Tiles</button>
|
||||
</div>
|
||||
<div className="stat-grid">
|
||||
{tiles.map((t) => {
|
||||
const value = tileValues[t];
|
||||
if (value == null) return null;
|
||||
return <StatTile key={t} label={tileDisplayLabel[t]} value={value} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatTile({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">{label}</div>
|
||||
<div className="stat-value">{fmtMoney(value)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tile configure modal (shared by all ledger tabs) ────────────────────────
|
||||
|
||||
function TileConfigModal({
|
||||
open,
|
||||
tiles,
|
||||
onChange,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
tiles: LedgerTile[];
|
||||
onChange: (tiles: LedgerTile[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const toggle = (t: LedgerTile) => {
|
||||
onChange(tiles.includes(t) ? tiles.filter((x) => x !== t) : [...tiles, t]);
|
||||
};
|
||||
return (
|
||||
<Modal open={open} title="Configure tiles" onClose={onClose} footer={
|
||||
<button className="btn btn-primary" onClick={onClose}>Done</button>
|
||||
}>
|
||||
<div className="flex-col gap-2">
|
||||
<p className="text-sm text-muted">Choose which tiles to display:</p>
|
||||
{ALL_LEDGER_TILES.map((t) => (
|
||||
<label key={t} className="checkbox">
|
||||
<input type="checkbox" checked={tiles.includes(t)} onChange={() => toggle(t)} />
|
||||
{LEDGER_TILE_LABELS[t]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
214
src/pages/SettingsPage.tsx
Normal file
214
src/pages/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* Settings — themes, default rate, and manual file import/export.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
import { THEME_NAMES } from '@/themes/ThemeProvider';
|
||||
import type { ThemeName, ThemeMode } from '@/types';
|
||||
import { isT99Encrypted } from '@/lib/storage/vault';
|
||||
|
||||
export function SettingsPage() {
|
||||
const settings = useAppStore((s) => s.data.settings);
|
||||
const setTheme = useAppStore((s) => s.setTheme);
|
||||
const setDefaultRate = useAppStore((s) => s.setDefaultRate);
|
||||
const exportFile = useAppStore((s) => s.exportFile);
|
||||
const importFile = useAppStore((s) => s.importFile);
|
||||
|
||||
const [exportPwd, setExportPwd] = useState('');
|
||||
|
||||
const [importFileObj, setImportFileObj] = useState<File | null>(null);
|
||||
const [importEncrypted, setImportEncrypted] = useState(false);
|
||||
const [importPwd, setImportPwd] = useState('');
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
await exportFile(exportPwd || undefined);
|
||||
setExportPwd('');
|
||||
} catch (e) {
|
||||
alert(`Export failed: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async (file: File | null) => {
|
||||
setImportFileObj(file);
|
||||
setImportPwd('');
|
||||
if (!file) { setImportEncrypted(false); return; }
|
||||
const content = await file.text();
|
||||
setImportEncrypted(isT99Encrypted(content));
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importFileObj) return;
|
||||
try {
|
||||
await importFile(importFileObj, importEncrypted ? importPwd : undefined);
|
||||
alert('Import successful');
|
||||
setImportFileObj(null);
|
||||
setImportEncrypted(false);
|
||||
setImportPwd('');
|
||||
} catch (e) {
|
||||
alert(`Import failed: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-col gap-4" style={{ maxWidth: 720, margin: '0 auto' }}>
|
||||
<h1>Settings</h1>
|
||||
|
||||
{/* ─── Theme ─────────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="card-title">Appearance</span></div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Theme</label>
|
||||
<select
|
||||
className="select"
|
||||
value={settings.theme}
|
||||
onChange={(e) => setTheme(e.target.value as ThemeName, settings.mode)}
|
||||
>
|
||||
{THEME_NAMES.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Mode</label>
|
||||
<div className="btn-group">
|
||||
{(['light', 'dark'] as ThemeMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`btn ${settings.mode === m ? 'active' : ''}`}
|
||||
onClick={() => setTheme(settings.theme, m)}
|
||||
>
|
||||
{m === 'light' ? '☀ Light' : '🌙 Dark'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8, marginTop: 12 }}>
|
||||
{THEME_NAMES.map((t) => (
|
||||
<ThemeSwatch
|
||||
key={t.id}
|
||||
theme={t.id}
|
||||
mode={settings.mode}
|
||||
label={t.label}
|
||||
active={settings.theme === t.id}
|
||||
onClick={() => setTheme(t.id, settings.mode)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Defaults ──────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="card-title">Defaults</span></div>
|
||||
<div className="field" style={{ maxWidth: 200 }}>
|
||||
<label>Default hourly rate ($/hr)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="input"
|
||||
value={settings.defaultRate}
|
||||
onChange={(e) => setDefaultRate(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Import / Export ───────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="card-title">Backup & Restore</span></div>
|
||||
|
||||
<div className="flex-col gap-2 mb-4">
|
||||
<p className="text-sm text-muted">
|
||||
Export a <code>.t99</code> backup file. Leave the password blank to export
|
||||
as plain JSON, or enter a password to encrypt the file.
|
||||
</p>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="field" style={{ flex: 1 }}>
|
||||
<label>Password <span className="text-muted">(optional)</span></label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={exportPwd}
|
||||
onChange={(e) => setExportPwd(e.target.value)}
|
||||
placeholder="Leave blank for unencrypted export"
|
||||
/>
|
||||
</div>
|
||||
<button className="btn" onClick={handleExport}>
|
||||
Export backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '8px 0' }} />
|
||||
|
||||
<div className="flex-col gap-2 mt-4">
|
||||
<p className="text-sm text-muted">
|
||||
Restore from a previously exported <code>.t99</code> file.
|
||||
This replaces all current data.
|
||||
</p>
|
||||
<div className="field">
|
||||
<label>Backup file</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".t99"
|
||||
onChange={(e) => handleFileSelect(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
{importEncrypted && (
|
||||
<div className="field">
|
||||
<label>File password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={importPwd}
|
||||
onChange={(e) => setImportPwd(e.target.value)}
|
||||
autoFocus
|
||||
placeholder="This file is encrypted — enter the password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleImport}
|
||||
disabled={!importFileObj || (importEncrypted && !importPwd)}
|
||||
>
|
||||
Restore from backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeSwatch({
|
||||
theme, mode, label, active, onClick,
|
||||
}: {
|
||||
theme: ThemeName; mode: ThemeMode; label: string; active: boolean; onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-theme={theme}
|
||||
data-mode={mode}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
border: active ? '2px solid var(--accent)' : '1px solid var(--border)',
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--fg)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 12, fontWeight: 600 }}>{label}</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
||||
<div style={{ width: 16, height: 16, borderRadius: 4, background: 'var(--accent)' }} />
|
||||
<div style={{ width: 16, height: 16, borderRadius: 4, background: 'var(--success)' }} />
|
||||
<div style={{ width: 16, height: 16, borderRadius: 4, background: 'var(--warning)' }} />
|
||||
<div style={{ width: 16, height: 16, borderRadius: 4, background: 'var(--danger)' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
458
src/pages/TaxPage.tsx
Normal file
458
src/pages/TaxPage.tsx
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
/**
|
||||
* Tax page — calculations, projections, quarterly schedule,
|
||||
* and highlighted prompts for missing inputs.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
import { calculateTax } from '@/lib/tax/calculate';
|
||||
import { availableTaxYears } from '@/lib/tax/brackets';
|
||||
import type { FilingStatus, TaxInputs, TaxTile } from '@/types';
|
||||
import { ChartSidebar } from '@/components/charts/ChartSidebar';
|
||||
import { Modal } from '@/components/common/Modal';
|
||||
import { fmtMoney, fmtMoneyShort, todayISO } from '@/lib/format';
|
||||
|
||||
const FILING_LABELS: Record<FilingStatus, string> = {
|
||||
single: 'Single',
|
||||
mfj: 'Married filing jointly',
|
||||
mfs: 'Married filing separately',
|
||||
hoh: 'Head of household',
|
||||
};
|
||||
|
||||
const ALL_TAX_TILES: TaxTile[] = [
|
||||
'grossReceipts', 'deductibleExpenses', 'netProfit',
|
||||
'seTaxableBase', 'socialSecurityTax', 'medicareTax', 'additionalMedicareTax', 'totalSETax',
|
||||
'seTaxDeduction', 'qbiDeduction', 'standardDeduction',
|
||||
'agi', 'taxableIncome', 'federalIncomeTax', 'totalFederalTax',
|
||||
'alreadyPaid', 'remainingDue',
|
||||
];
|
||||
|
||||
const TAX_TILE_LABELS: Record<TaxTile, string> = {
|
||||
grossReceipts: 'Gross Receipts',
|
||||
deductibleExpenses: 'Deductible Expenses',
|
||||
netProfit: 'Net Profit',
|
||||
seTaxableBase: 'SE Taxable Base',
|
||||
socialSecurityTax: 'Social Security Tax',
|
||||
medicareTax: 'Medicare Tax',
|
||||
additionalMedicareTax: 'Additional Medicare Tax',
|
||||
totalSETax: 'Total SE Tax',
|
||||
seTaxDeduction: 'SE Tax Deduction',
|
||||
qbiDeduction: 'QBI Deduction (§199A)',
|
||||
standardDeduction: 'Standard Deduction',
|
||||
agi: 'Adjusted Gross Income',
|
||||
taxableIncome: 'Taxable Income',
|
||||
federalIncomeTax: 'Federal Income Tax',
|
||||
totalFederalTax: 'Total Federal Tax',
|
||||
alreadyPaid: 'Already Paid',
|
||||
remainingDue: 'Remaining Due',
|
||||
};
|
||||
|
||||
// Tiles that represent money owed (red), vs money saved/paid (green)
|
||||
const TAX_TILE_NEGATIVE = new Set<TaxTile>([
|
||||
'totalFederalTax', 'federalIncomeTax', 'totalSETax',
|
||||
'socialSecurityTax', 'medicareTax', 'additionalMedicareTax', 'remainingDue',
|
||||
]);
|
||||
const TAX_TILE_POSITIVE = new Set<TaxTile>(['alreadyPaid']);
|
||||
|
||||
export function TaxPage() {
|
||||
const payments = useAppStore((s) => s.data.payments);
|
||||
const expenses = useAppStore((s) => s.data.expenses);
|
||||
const taxInputs = useAppStore((s) => s.data.taxInputs);
|
||||
const setTaxInputs = useAppStore((s) => s.setTaxInputs);
|
||||
const addTaxPayment = useAppStore((s) => s.addTaxPayment);
|
||||
const deleteTaxPayment = useAppStore((s) => s.deleteTaxPayment);
|
||||
const taxTiles = useAppStore((s) => s.data.dashboard.taxTiles);
|
||||
const setPageTiles = useAppStore((s) => s.setPageTiles);
|
||||
|
||||
const years = availableTaxYears();
|
||||
const [year, setYear] = useState(years[0] ?? new Date().getFullYear());
|
||||
const [tilesConfigOpen, setTilesConfigOpen] = useState(false);
|
||||
const [newPaymentDate, setNewPaymentDate] = useState(todayISO());
|
||||
const [newPaymentAmount, setNewPaymentAmount] = useState('');
|
||||
const [newPaymentQuarter, setNewPaymentQuarter] = useState<string>('');
|
||||
const [newPaymentNote, setNewPaymentNote] = useState('');
|
||||
|
||||
const inputs: TaxInputs = taxInputs[year] ?? { taxYear: year, filingStatus: 'single' };
|
||||
|
||||
// Auto-populate prior year data from stored tax results if not manually entered
|
||||
const prevYearInputs = taxInputs[year - 1];
|
||||
const prevYearResult = useMemo(
|
||||
() => prevYearInputs ? calculateTax(payments, expenses, prevYearInputs, { project: false }) : null,
|
||||
[payments, expenses, prevYearInputs],
|
||||
);
|
||||
const effectiveInputs: TaxInputs = useMemo(() => ({
|
||||
...inputs,
|
||||
priorYearTax: inputs.priorYearTax ?? prevYearResult?.totalFederalTax,
|
||||
priorYearAGI: inputs.priorYearAGI ?? prevYearResult?.agi,
|
||||
}), [inputs, prevYearResult]);
|
||||
|
||||
const isCurrentYear = year === new Date().getFullYear();
|
||||
|
||||
const result = useMemo(
|
||||
() => calculateTax(payments, expenses, effectiveInputs, { project: false }),
|
||||
[payments, expenses, effectiveInputs],
|
||||
);
|
||||
|
||||
const projResult = useMemo(
|
||||
() => isCurrentYear ? calculateTax(payments, expenses, effectiveInputs, { project: true }) : null,
|
||||
[payments, expenses, effectiveInputs, isCurrentYear],
|
||||
);
|
||||
|
||||
const updateInput = (patch: Partial<TaxInputs>) => setTaxInputs(year, patch);
|
||||
|
||||
const submitPayment = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newPaymentAmount) return;
|
||||
addTaxPayment(year, {
|
||||
date: newPaymentDate,
|
||||
amount: parseFloat(newPaymentAmount),
|
||||
quarter: newPaymentQuarter ? (parseInt(newPaymentQuarter) as 1|2|3|4) : undefined,
|
||||
note: newPaymentNote || undefined,
|
||||
});
|
||||
setNewPaymentAmount('');
|
||||
setNewPaymentNote('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="split-layout">
|
||||
<div className="left">
|
||||
{/* ─── Year / filing status / projection toggle ───────────────── */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted text-sm">Tax Year</span>
|
||||
<select
|
||||
className="select"
|
||||
style={{ width: 120 }}
|
||||
value={year}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted text-sm">Filing Status</span>
|
||||
<select
|
||||
className="select"
|
||||
style={{ width: 200 }}
|
||||
value={inputs.filingStatus}
|
||||
onChange={(e) => updateInput({ filingStatus: e.target.value as FilingStatus })}
|
||||
>
|
||||
{(Object.keys(FILING_LABELS) as FilingStatus[]).map((s) => (
|
||||
<option key={s} value={s}>{FILING_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{isCurrentYear && (
|
||||
<span className="text-sm text-muted">Projected full-year values shown alongside actuals</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Highlighted prompts for missing data ───────────────────── */}
|
||||
{result.prompts.length > 0 && (
|
||||
<div className="flex-col gap-2">
|
||||
{result.prompts.map((p) => {
|
||||
const autoValue = effectiveInputs[p.field];
|
||||
const isAutoFilled = autoValue != null && inputs[p.field] == null;
|
||||
return (
|
||||
<div key={p.field} className="field-prompt">
|
||||
<div className="field">
|
||||
<label>
|
||||
{p.label}
|
||||
{isAutoFilled && <span className="text-muted text-sm"> (auto from {year - 1})</span>}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="input"
|
||||
placeholder={isAutoFilled ? String(autoValue) : 'Enter value...'}
|
||||
value={inputs[p.field] ?? ''}
|
||||
onChange={(e) =>
|
||||
updateInput({ [p.field]: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="prompt-reason">{p.reason}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Configurable summary tiles ──────────────────────────────── */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 4 }}>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => setTilesConfigOpen(true)}>⚙ Tiles</button>
|
||||
</div>
|
||||
<div className="stat-grid">
|
||||
{taxTiles.map((t) => {
|
||||
const value: number = (result as unknown as Record<string, number>)[t] ?? 0;
|
||||
const projValue: number | undefined = projResult
|
||||
? (projResult as unknown as Record<string, number>)[t]
|
||||
: undefined;
|
||||
const className = TAX_TILE_NEGATIVE.has(t) ? 'negative' : TAX_TILE_POSITIVE.has(t) ? 'positive' : '';
|
||||
const sub = t === 'remainingDue'
|
||||
? (result.safeHarborMet === true ? '✓ Safe harbor met'
|
||||
: result.safeHarborMet === false ? '⚠ Safe harbor NOT met'
|
||||
: 'Safe harbor: need prior year data')
|
||||
: t === 'alreadyPaid' ? 'Withholding + estimates'
|
||||
: undefined;
|
||||
return (
|
||||
<div key={t} className={`stat-card ${className}`}>
|
||||
<div className="stat-label">{TAX_TILE_LABELS[t]}</div>
|
||||
<div className="stat-value">{fmtMoneyShort(value)}</div>
|
||||
{projValue !== undefined && (
|
||||
<div className="stat-sub">projected: {fmtMoneyShort(projValue)}</div>
|
||||
)}
|
||||
{sub && !projValue && <div className="stat-sub">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={tilesConfigOpen}
|
||||
title="Configure tax tiles"
|
||||
onClose={() => setTilesConfigOpen(false)}
|
||||
footer={<button className="btn btn-primary" onClick={() => setTilesConfigOpen(false)}>Done</button>}
|
||||
>
|
||||
<div className="flex-col gap-2">
|
||||
<p className="text-sm text-muted">Choose which values to display as tiles:</p>
|
||||
{ALL_TAX_TILES.map((t) => (
|
||||
<label key={t} className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={taxTiles.includes(t)}
|
||||
onChange={() => {
|
||||
const next = taxTiles.includes(t)
|
||||
? taxTiles.filter((x) => x !== t)
|
||||
: [...taxTiles, t];
|
||||
setPageTiles('tax', next);
|
||||
}}
|
||||
/>
|
||||
{TAX_TILE_LABELS[t]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ─── Quarterly schedule ─────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">Quarterly Estimated Payments</span>
|
||||
{result.safeHarborAmount != null && (
|
||||
<span className="text-sm text-muted">
|
||||
Safe harbor: {fmtMoney(result.safeHarborAmount)}/yr
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Q</th>
|
||||
<th>Due</th>
|
||||
<th className="num">Per-quarter (actual)</th>
|
||||
{projResult && <th className="num" style={{ color: 'var(--fg-muted)' }}>Per-quarter (proj)</th>}
|
||||
<th className="num">Remaining / Q</th>
|
||||
<th className="num">Safe Harbor Min</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.quarterlySchedule.map((q, i) => (
|
||||
<tr key={q.quarter}>
|
||||
<td>Q{q.quarter}</td>
|
||||
<td>{q.dueDate}</td>
|
||||
<td className="num">{fmtMoney(q.projectedAmount)}</td>
|
||||
{projResult && (
|
||||
<td className="num" style={{ color: 'var(--fg-muted)' }}>
|
||||
{fmtMoney(projResult.quarterlySchedule[i]?.projectedAmount)}
|
||||
</td>
|
||||
)}
|
||||
<td className="num" style={{ fontWeight: q.isPastDue ? 400 : 600 }}>{fmtMoney(q.remainingAmount)}</td>
|
||||
<td className="num">{q.safeHarborAmount != null ? fmtMoney(q.safeHarborAmount) : '—'}</td>
|
||||
<td>{q.isPastDue && <span className="text-danger text-sm">past due</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{prevYearResult && !inputs.priorYearTax && (
|
||||
<div className="mt-2 text-sm text-muted">
|
||||
✓ Prior year data auto-loaded from {year - 1} records (tax: {fmtMoney(prevYearResult.totalFederalTax)}, AGI: {fmtMoney(prevYearResult.agi)})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Tax payment log ─────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="card-title">Estimated Tax Payments Made</span></div>
|
||||
{(inputs.taxPayments ?? []).length > 0 && (
|
||||
<table className="data-table" style={{ marginBottom: 12 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Q</th>
|
||||
<th className="num">Amount</th>
|
||||
<th>Note</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(inputs.taxPayments ?? []).map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.date}</td>
|
||||
<td>{p.quarter ? `Q${p.quarter}` : '—'}</td>
|
||||
<td className="num">{fmtMoney(p.amount)}</td>
|
||||
<td className="text-muted text-sm">{p.note ?? ''}</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost text-danger"
|
||||
onClick={() => deleteTaxPayment(year, p.id)}
|
||||
title="Delete"
|
||||
>✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr style={{ fontWeight: 600 }}>
|
||||
<td colSpan={2}>Total logged</td>
|
||||
<td className="num">{fmtMoney((inputs.taxPayments ?? []).reduce((s, p) => s + p.amount, 0))}</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<form onSubmit={submitPayment} className="flex items-center gap-2 flex-wrap">
|
||||
<input
|
||||
type="date" className="input" style={{ width: 148 }}
|
||||
value={newPaymentDate} onChange={(e) => setNewPaymentDate(e.target.value)} required
|
||||
/>
|
||||
<select
|
||||
className="select" style={{ width: 90 }}
|
||||
value={newPaymentQuarter} onChange={(e) => setNewPaymentQuarter(e.target.value)}
|
||||
>
|
||||
<option value="">Q?</option>
|
||||
<option value="1">Q1</option>
|
||||
<option value="2">Q2</option>
|
||||
<option value="3">Q3</option>
|
||||
<option value="4">Q4</option>
|
||||
</select>
|
||||
<input
|
||||
type="number" step="0.01" className="input" style={{ width: 120 }}
|
||||
placeholder="Amount" value={newPaymentAmount}
|
||||
onChange={(e) => setNewPaymentAmount(e.target.value)} required
|
||||
/>
|
||||
<input
|
||||
className="input" style={{ flex: 1, minWidth: 100 }}
|
||||
placeholder="Note (optional)" value={newPaymentNote}
|
||||
onChange={(e) => setNewPaymentNote(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sm">+ Log payment</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* ─── Full breakdown ──────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="card-title">Calculation Breakdown</span></div>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th className="num">Actual YTD</th>
|
||||
{projResult && <th className="num" style={{ color: 'var(--fg-muted)' }}>Projected</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<BreakdownRow label="Gross receipts (payments)" value={result.grossReceipts} proj={projResult?.grossReceipts} />
|
||||
<BreakdownRow label="− Deductible expenses" value={-result.deductibleExpenses} proj={projResult ? -projResult.deductibleExpenses : undefined} />
|
||||
<BreakdownRow label="= Net profit (Schedule C)" value={result.netProfit} proj={projResult?.netProfit} bold />
|
||||
<BreakdownSep cols={projResult ? 3 : 2} />
|
||||
<BreakdownRow label="SE taxable base (× 92.35%)" value={result.seTaxableBase} proj={projResult?.seTaxableBase} />
|
||||
<BreakdownRow label="Social Security tax (12.4%, capped)" value={result.socialSecurityTax} proj={projResult?.socialSecurityTax} />
|
||||
<BreakdownRow label="Medicare tax (2.9%)" value={result.medicareTax} proj={projResult?.medicareTax} />
|
||||
<BreakdownRow label="Additional Medicare (0.9%)" value={result.additionalMedicareTax} proj={projResult?.additionalMedicareTax} />
|
||||
<BreakdownRow label="= Total SE tax" value={result.totalSETax} proj={projResult?.totalSETax} bold />
|
||||
<BreakdownSep cols={projResult ? 3 : 2} />
|
||||
<BreakdownRow label="− ½ SE tax deduction" value={-result.seTaxDeduction} proj={projResult ? -projResult.seTaxDeduction : undefined} />
|
||||
<BreakdownRow label="− Standard deduction" value={-result.standardDeduction} proj={projResult ? -projResult.standardDeduction : undefined} />
|
||||
<BreakdownRow label="− QBI deduction (§199A)" value={-result.qbiDeduction} proj={projResult ? -projResult.qbiDeduction : undefined} />
|
||||
<BreakdownRow label="= Taxable income" value={result.taxableIncome} proj={projResult?.taxableIncome} bold />
|
||||
<BreakdownRow label="Federal income tax" value={result.federalIncomeTax} proj={projResult?.federalIncomeTax} bold />
|
||||
<BreakdownSep cols={projResult ? 3 : 2} />
|
||||
<BreakdownRow label="TOTAL FEDERAL TAX" value={result.totalFederalTax} proj={projResult?.totalFederalTax} bold highlight />
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{(result.notes.length > 0 || (projResult?.notes.length ?? 0) > 0) && (
|
||||
<div className="mt-4 text-sm text-muted">
|
||||
{result.notes.map((n, i) => <div key={i}>• {n}</div>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-sm text-muted">
|
||||
⚠ This is a planning estimator, not tax advice. Consult a tax professional.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Additional inputs ───────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="card-title">Additional Inputs</span></div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>W-2 wages</label>
|
||||
<input type="number" step="0.01" className="input" value={inputs.w2Wages ?? ''}
|
||||
onChange={(e) => updateInput({ w2Wages: e.target.value ? Number(e.target.value) : undefined })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Federal withholding</label>
|
||||
<input type="number" step="0.01" className="input" value={inputs.federalWithholding ?? ''}
|
||||
onChange={(e) => updateInput({ federalWithholding: e.target.value ? Number(e.target.value) : undefined })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Est. payments made</label>
|
||||
<input type="number" step="0.01" className="input" value={inputs.estimatedPaymentsMade ?? ''}
|
||||
onChange={(e) => updateInput({ estimatedPaymentsMade: e.target.value ? Number(e.target.value) : undefined })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row mt-2">
|
||||
<div className="field">
|
||||
<label>Prior year AGI</label>
|
||||
<input type="number" step="0.01" className="input" value={inputs.priorYearAGI ?? ''}
|
||||
onChange={(e) => updateInput({ priorYearAGI: e.target.value ? Number(e.target.value) : undefined })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Prior year tax</label>
|
||||
<input type="number" step="0.01" className="input" value={inputs.priorYearTax ?? ''}
|
||||
onChange={(e) => updateInput({ priorYearTax: e.target.value ? Number(e.target.value) : undefined })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="right">
|
||||
<ChartSidebar tab="tax" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakdownRow({ label, value, proj, bold, highlight }: {
|
||||
label: string; value: number; proj?: number; bold?: boolean; highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<tr style={{ fontWeight: bold ? 600 : 400, background: highlight ? 'var(--accent-muted)' : undefined }}>
|
||||
<td>{label}</td>
|
||||
<td className="num">{fmtMoney(value)}</td>
|
||||
{proj !== undefined && (
|
||||
<td className="num" style={{ color: 'var(--fg-muted)' }}>{fmtMoney(proj)}</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakdownSep({ cols = 2 }: { cols?: number }) {
|
||||
return <tr><td colSpan={cols} style={{ padding: 2, background: 'var(--bg-elev-2)' }}></td></tr>;
|
||||
}
|
||||
373
src/pages/TimerPage.tsx
Normal file
373
src/pages/TimerPage.tsx
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
/**
|
||||
* Live Work Timer — start / pause / split with crash recovery.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTimerStore } from '@/store/timerStore';
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
import type { TimerSplit } from '@/types';
|
||||
import {
|
||||
fmtDuration,
|
||||
fmtDurationVerbose,
|
||||
fmtMoney,
|
||||
totalMinutes,
|
||||
msToHours,
|
||||
todayISO,
|
||||
} from '@/lib/format';
|
||||
import { Modal, ConfirmDialog } from '@/components/common/Modal';
|
||||
|
||||
export function TimerPage() {
|
||||
const t = useTimerStore();
|
||||
const addWorkEntry = useAppStore((s) => s.addWorkEntry);
|
||||
const defaultRate = useAppStore((s) => s.data.settings.defaultRate);
|
||||
|
||||
const [editSplit, setEditSplit] = useState<TimerSplit | null>(null);
|
||||
const [editDraft, setEditDraft] = useState<Partial<TimerSplit>>({});
|
||||
const [confirmEdit, setConfirmEdit] = useState(false);
|
||||
const [recordSplit, setRecordSplit] = useState<TimerSplit | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
|
||||
const liveEarned = msToHours(t.elapsedMs) * t.currentRate;
|
||||
|
||||
const totalSplitMs = t.splits.reduce((s, x) => s + x.elapsedMs, 0);
|
||||
const totalSplitEarned = t.splits.reduce(
|
||||
(s, x) => s + msToHours(x.elapsedMs) * x.rate,
|
||||
0,
|
||||
);
|
||||
|
||||
// ─── Edit flow: two-step confirm to prevent accidents ──────────────────────
|
||||
|
||||
const openEdit = (sp: TimerSplit) => {
|
||||
setEditSplit(sp);
|
||||
setEditDraft({
|
||||
label: sp.label,
|
||||
elapsedMs: sp.elapsedMs,
|
||||
rate: sp.rate,
|
||||
});
|
||||
};
|
||||
|
||||
const stageEdit = () => setConfirmEdit(true);
|
||||
|
||||
const commitEdit = () => {
|
||||
if (!editSplit) return;
|
||||
t.updateSplit(editSplit.id, editDraft);
|
||||
setConfirmEdit(false);
|
||||
setEditSplit(null);
|
||||
setEditDraft({});
|
||||
};
|
||||
|
||||
// ─── Record-to-work-log flow ───────────────────────────────────────────────
|
||||
|
||||
const [recordMode, setRecordMode] = useState<'item' | 'daily'>('item');
|
||||
const [recordDate, setRecordDate] = useState(todayISO());
|
||||
const [recordDesc, setRecordDesc] = useState('');
|
||||
|
||||
const openRecord = (sp: TimerSplit) => {
|
||||
setRecordSplit(sp);
|
||||
setRecordDate(todayISO());
|
||||
setRecordDesc(sp.label ?? `Timer split (${fmtDurationVerbose(sp.elapsedMs)})`);
|
||||
setRecordMode('item');
|
||||
};
|
||||
|
||||
const commitRecord = () => {
|
||||
if (!recordSplit) return;
|
||||
const hours = msToHours(recordSplit.elapsedMs);
|
||||
const entry = addWorkEntry({
|
||||
date: recordDate,
|
||||
description:
|
||||
recordMode === 'daily'
|
||||
? `[Daily timer] ${recordDesc}`
|
||||
: recordDesc,
|
||||
hours,
|
||||
rate: recordSplit.rate,
|
||||
});
|
||||
t.markRecorded(recordSplit.id, entry.id);
|
||||
setRecordSplit(null);
|
||||
};
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="flex-col gap-4">
|
||||
{/* ─── Crash recovery banner ───────────────────────────────────────── */}
|
||||
{t.crashRecovery && (
|
||||
<div className="crash-banner">
|
||||
<strong>⚠ Timer recovered from unexpected close</strong>
|
||||
<div className="crash-grid">
|
||||
<span>Crash / close time:</span>
|
||||
<span>{new Date(t.crashRecovery.crashTime).toLocaleString()}</span>
|
||||
<span>Reload time:</span>
|
||||
<span>{new Date(t.crashRecovery.reloadTime).toLocaleString()}</span>
|
||||
<span>Gap:</span>
|
||||
<span>{fmtDurationVerbose(t.crashRecovery.gapMs)}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
The clock kept running based on the original start time. If you
|
||||
weren't actually working during the gap, subtract it below.
|
||||
</div>
|
||||
<div className="crash-actions">
|
||||
<button className="btn btn-sm btn-danger" onClick={t.subtractCrashGap}>
|
||||
Subtract {fmtDuration(t.crashRecovery.gapMs)} from clock
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={t.dismissCrashBanner}>
|
||||
Keep — I was working
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Live clock ──────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="timer-display">{fmtDuration(t.elapsedMs)}</div>
|
||||
<div className="timer-earned">{fmtMoney(liveEarned)}</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2" style={{ justifyContent: 'center' }}>
|
||||
<span className="text-muted text-sm">Rate $/hr</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="input"
|
||||
style={{ width: 100 }}
|
||||
value={t.currentRate}
|
||||
onChange={(e) => t.setRate(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => t.setRate(defaultRate)}
|
||||
title="Reset to default rate"
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="timer-controls">
|
||||
{!t.running ? (
|
||||
<button className="btn btn-primary btn-lg" onClick={t.start}>
|
||||
▶ Start
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-lg" onClick={t.pause}>
|
||||
⏸ Pause
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-lg"
|
||||
onClick={() => t.split()}
|
||||
disabled={t.elapsedMs === 0}
|
||||
>
|
||||
⏭ Split
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-lg text-danger"
|
||||
onClick={t.reset}
|
||||
disabled={t.elapsedMs === 0 && t.splits.length === 0}
|
||||
>
|
||||
⟲ Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Splits table ────────────────────────────────────────────────── */}
|
||||
<div className="card scroll-y" style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className="card-header">
|
||||
<span className="card-title">Splits</span>
|
||||
</div>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th className="num">Time (h:m:s)</th>
|
||||
<th className="num col-hide-sm">Minutes</th>
|
||||
<th className="num">Rate</th>
|
||||
<th className="num">Earned</th>
|
||||
<th style={{ width: 40 }}></th>
|
||||
<th style={{ width: 120 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{t.splits.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-muted" style={{ textAlign: 'center', padding: 20 }}>
|
||||
No splits yet. Press <strong>Split</strong> to record the current clock segment.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{t.splits.map((sp) => {
|
||||
const earned = msToHours(sp.elapsedMs) * sp.rate;
|
||||
return (
|
||||
<tr key={sp.id}>
|
||||
<td>{sp.label ?? <span className="text-muted">—</span>}</td>
|
||||
<td className="num mono">{fmtDurationVerbose(sp.elapsedMs)}</td>
|
||||
<td className="num mono col-hide-sm">{totalMinutes(sp.elapsedMs)}</td>
|
||||
<td className="num mono">{fmtMoney(sp.rate)}</td>
|
||||
<td className="num mono">{fmtMoney(earned)}</td>
|
||||
<td>
|
||||
{sp.recorded && <span className="recorded-badge">✓ Logged</span>}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-1">
|
||||
{!sp.recorded && (
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => openRecord(sp)}
|
||||
title="Record to work log"
|
||||
>
|
||||
→ Log
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => openEdit(sp)} title="Edit">
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost text-danger"
|
||||
onClick={() => setConfirmDeleteId(sp.id)}
|
||||
title="Delete"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>Total</td>
|
||||
<td className="num mono">{fmtDurationVerbose(totalSplitMs)}</td>
|
||||
<td className="num mono col-hide-sm">{totalMinutes(totalSplitMs)}</td>
|
||||
<td></td>
|
||||
<td className="num mono">{fmtMoney(totalSplitEarned)}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ─── Edit split modal ────────────────────────────────────────────── */}
|
||||
<Modal
|
||||
open={editSplit != null && !confirmEdit}
|
||||
title="Edit split"
|
||||
onClose={() => setEditSplit(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={() => setEditSplit(null)}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={stageEdit}>Save</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex-col gap-3">
|
||||
<div className="field">
|
||||
<label>Label</label>
|
||||
<input
|
||||
className="input"
|
||||
value={editDraft.label ?? ''}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Minutes</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
value={editDraft.elapsedMs != null ? Math.round(editDraft.elapsedMs / 60000) : ''}
|
||||
onChange={(e) =>
|
||||
setEditDraft({ ...editDraft, elapsedMs: (parseInt(e.target.value) || 0) * 60000 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Rate ($/hr)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="input"
|
||||
value={editDraft.rate ?? ''}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, rate: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmEdit}
|
||||
title="Confirm edit"
|
||||
message="Are you sure you want to change this split? This can't be undone."
|
||||
confirmLabel="Yes, save changes"
|
||||
onConfirm={commitEdit}
|
||||
onCancel={() => setConfirmEdit(false)}
|
||||
/>
|
||||
|
||||
{/* ─── Record-to-log modal ─────────────────────────────────────────── */}
|
||||
<Modal
|
||||
open={recordSplit != null}
|
||||
title="Record to work log"
|
||||
onClose={() => setRecordSplit(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={() => setRecordSplit(null)}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={commitRecord}>Record</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{recordSplit && (
|
||||
<div className="flex-col gap-3">
|
||||
<div className="btn-group">
|
||||
<button
|
||||
className={`btn btn-sm ${recordMode === 'item' ? 'active' : ''}`}
|
||||
onClick={() => setRecordMode('item')}
|
||||
>
|
||||
As individual item
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${recordMode === 'daily' ? 'active' : ''}`}
|
||||
onClick={() => setRecordMode('daily')}
|
||||
>
|
||||
Add to day total
|
||||
</button>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={recordDate}
|
||||
onChange={(e) => setRecordDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Description</label>
|
||||
<input
|
||||
className="input"
|
||||
value={recordDesc}
|
||||
onChange={(e) => setRecordDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted">
|
||||
Will log <span className="mono">{fmtDurationVerbose(recordSplit.elapsedMs)}</span> @{' '}
|
||||
<span className="mono">{fmtMoney(recordSplit.rate)}/hr</span> ={' '}
|
||||
<span className="mono">{fmtMoney(msToHours(recordSplit.elapsedMs) * recordSplit.rate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ─── Delete confirm ─────────────────────────────────────────────── */}
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteId != null}
|
||||
title="Delete split?"
|
||||
message="This will permanently remove this split from the table."
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={() => {
|
||||
if (confirmDeleteId) t.deleteSplit(confirmDeleteId);
|
||||
setConfirmDeleteId(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDeleteId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
538
src/store/appStore.ts
Normal file
538
src/store/appStore.ts
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
/**
|
||||
* Zustand store — single source of truth for all app data.
|
||||
* Persistence is manual (we call vault.save() after mutations) so we can
|
||||
* route through the encryption layer.
|
||||
*
|
||||
* Storage is always cookie-based. A device-local key (stored in localStorage)
|
||||
* is used as the encryption password — no user password required.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type {
|
||||
AppData,
|
||||
WorkEntry,
|
||||
Payment,
|
||||
Expense,
|
||||
RecurringExpense,
|
||||
TaxInputs,
|
||||
TaxPaymentRecord,
|
||||
Settings,
|
||||
DashboardConfig,
|
||||
ChartConfig,
|
||||
LedgerTile,
|
||||
TaxTile,
|
||||
ThemeName,
|
||||
ThemeMode,
|
||||
} from '@/types';
|
||||
import { uid } from '@/lib/id';
|
||||
import { Vault, deserializeT99 } from '@/lib/storage/vault';
|
||||
import { todayISO } from '@/lib/format';
|
||||
|
||||
// ─── Defaults ────────────────────────────────────────────────────────────────
|
||||
|
||||
const defaultLedgerChart = (metric: 'workValue' | 'payments' | 'expenses', title: string, rollingAvgWindow?: number): ChartConfig => ({
|
||||
id: uid(),
|
||||
type: 'area',
|
||||
metrics: [metric],
|
||||
granularity: 'day',
|
||||
rangeStart: null,
|
||||
rangeEnd: null,
|
||||
yMin: null,
|
||||
yMax: null,
|
||||
title,
|
||||
rollingAvgWindow: rollingAvgWindow ?? null,
|
||||
});
|
||||
|
||||
const defaultDashboard = (): DashboardConfig => ({
|
||||
charts: [
|
||||
{
|
||||
id: uid(),
|
||||
type: 'area',
|
||||
metrics: ['workValue', 'payments', 'expenses'],
|
||||
granularity: 'day',
|
||||
rangeStart: null,
|
||||
rangeEnd: null,
|
||||
yMin: null,
|
||||
yMax: null,
|
||||
title: 'Work, Income & Expenses',
|
||||
},
|
||||
],
|
||||
widgets: [
|
||||
'ytdWorkValue',
|
||||
'ytdWorkProj',
|
||||
'ytdPayments',
|
||||
'ytdPaymentsProj',
|
||||
'ytdExpenses',
|
||||
'ytdNet',
|
||||
'ytdNetProj',
|
||||
'nextQuarterlyDue',
|
||||
'projectedAnnualTax',
|
||||
'ytdActualTax',
|
||||
'taxRemainingDue',
|
||||
],
|
||||
workCharts: [defaultLedgerChart('workValue', 'Work Value by Day', 10)],
|
||||
paymentsCharts: [defaultLedgerChart('payments', 'Payments by Day')],
|
||||
expensesCharts: [defaultLedgerChart('expenses', 'Expenses by Day')],
|
||||
taxCharts: [],
|
||||
workTiles: ['ytd', 'avgMonth', 'yearProj', 'thisMonth', 'avgDay', 'monthProj', 'today'] as LedgerTile[],
|
||||
paymentsTiles: ['ytd', 'avgMonth', 'yearProj', 'thisMonth', 'avgDay', 'monthProj', 'today'] as LedgerTile[],
|
||||
expensesTiles: ['ytd', 'avgMonth', 'yearProj', 'thisMonth', 'avgDay', 'monthProj', 'today'] as LedgerTile[],
|
||||
taxTiles: ['totalFederalTax', 'alreadyPaid', 'remainingDue'] as TaxTile[],
|
||||
});
|
||||
|
||||
const defaultSettings = (): Settings => ({
|
||||
theme: 'standard',
|
||||
mode: 'dark',
|
||||
defaultRate: 50,
|
||||
});
|
||||
|
||||
const defaultData = (): AppData => ({
|
||||
workEntries: [],
|
||||
payments: [],
|
||||
expenses: [],
|
||||
recurringExpenses: [],
|
||||
taxInputs: {},
|
||||
dashboard: defaultDashboard(),
|
||||
settings: defaultSettings(),
|
||||
version: 1,
|
||||
});
|
||||
|
||||
// ─── Recurring expense helper ─────────────────────────────────────────────────
|
||||
|
||||
function generateOccurrences(re: RecurringExpense, upTo: string): string[] {
|
||||
const results: string[] = [];
|
||||
const maxDate = new Date(upTo + 'T00:00:00');
|
||||
const endDate = re.endDate ? new Date(re.endDate + 'T00:00:00') : maxDate;
|
||||
const limit = endDate < maxDate ? endDate : maxDate;
|
||||
|
||||
let cur = new Date(re.startDate + 'T00:00:00');
|
||||
let safety = 1000;
|
||||
while (cur <= limit && safety-- > 0) {
|
||||
results.push(cur.toISOString().slice(0, 10));
|
||||
switch (re.frequency) {
|
||||
case 'weekly':
|
||||
cur.setDate(cur.getDate() + 7);
|
||||
break;
|
||||
case 'biweekly':
|
||||
cur.setDate(cur.getDate() + 14);
|
||||
break;
|
||||
case 'monthly': {
|
||||
const y = cur.getMonth() === 11 ? cur.getFullYear() + 1 : cur.getFullYear();
|
||||
const m = (cur.getMonth() + 1) % 12;
|
||||
const maxDay = new Date(y, m + 1, 0).getDate();
|
||||
cur = new Date(y, m, Math.min(re.dayOfMonth, maxDay));
|
||||
break;
|
||||
}
|
||||
case 'quarterly': {
|
||||
const totalMonths = cur.getMonth() + 3;
|
||||
const y = cur.getFullYear() + Math.floor(totalMonths / 12);
|
||||
const m = totalMonths % 12;
|
||||
const maxDay = new Date(y, m + 1, 0).getDate();
|
||||
cur = new Date(y, m, Math.min(re.dayOfMonth, maxDay));
|
||||
break;
|
||||
}
|
||||
case 'annually':
|
||||
cur = new Date(cur.getFullYear() + 1, cur.getMonth(), cur.getDate());
|
||||
break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Anonymous vault helper ───────────────────────────────────────────────────
|
||||
|
||||
function getDeviceKey(): string {
|
||||
const stored = localStorage.getItem('t99_device_key');
|
||||
if (stored) return stored;
|
||||
const key = crypto.randomUUID();
|
||||
localStorage.setItem('t99_device_key', key);
|
||||
return key;
|
||||
}
|
||||
|
||||
// ─── Store shape ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface AppStore {
|
||||
// Data
|
||||
data: AppData;
|
||||
// Internals
|
||||
vault: Vault | null;
|
||||
ready: boolean;
|
||||
saving: boolean;
|
||||
lastSaveError: string | null;
|
||||
|
||||
// ─── Init ─────────────────────────────────────────────────────────────────
|
||||
/** Boot the anonymous cookie vault. Called once on app mount. */
|
||||
init: () => Promise<void>;
|
||||
|
||||
// ─── CRUD: Work ───────────────────────────────────────────────────────────
|
||||
addWorkEntry: (e: Omit<WorkEntry, 'id' | 'createdAt' | 'updatedAt'>) => WorkEntry;
|
||||
updateWorkEntry: (id: string, patch: Partial<WorkEntry>) => void;
|
||||
deleteWorkEntry: (id: string) => void;
|
||||
|
||||
// ─── CRUD: Payments ───────────────────────────────────────────────────────
|
||||
addPayment: (p: Omit<Payment, 'id' | 'createdAt' | 'updatedAt'>) => Payment;
|
||||
updatePayment: (id: string, patch: Partial<Payment>) => void;
|
||||
deletePayment: (id: string) => void;
|
||||
|
||||
// ─── CRUD: Expenses ───────────────────────────────────────────────────────
|
||||
addExpense: (e: Omit<Expense, 'id' | 'createdAt' | 'updatedAt'>) => Expense;
|
||||
updateExpense: (id: string, patch: Partial<Expense>) => void;
|
||||
deleteExpense: (id: string) => void;
|
||||
|
||||
// ─── Recurring expenses ───────────────────────────────────────────────────
|
||||
addRecurringExpense: (e: Omit<RecurringExpense, 'id' | 'createdAt' | 'updatedAt'>) => RecurringExpense;
|
||||
updateRecurringExpense: (id: string, patch: Partial<RecurringExpense>) => void;
|
||||
deleteRecurringExpense: (id: string) => void;
|
||||
/** Generate Expense entries for all un-logged recurring occurrences up to today */
|
||||
applyRecurringExpenses: () => void;
|
||||
|
||||
// ─── Tax inputs ───────────────────────────────────────────────────────────
|
||||
setTaxInputs: (year: number, inputs: Partial<TaxInputs>) => void;
|
||||
addTaxPayment: (year: number, p: Omit<TaxPaymentRecord, 'id'>) => void;
|
||||
deleteTaxPayment: (year: number, id: string) => void;
|
||||
|
||||
// ─── Dashboard ────────────────────────────────────────────────────────────
|
||||
addChart: (c?: Partial<ChartConfig>) => void;
|
||||
updateChart: (id: string, patch: Partial<ChartConfig>) => void;
|
||||
removeChart: (id: string) => void;
|
||||
setDashboardWidgets: (w: DashboardConfig['widgets']) => void;
|
||||
|
||||
addLedgerChart: (tab: 'work' | 'payments' | 'expenses' | 'tax', c?: Partial<ChartConfig>) => void;
|
||||
updateLedgerChart: (tab: 'work' | 'payments' | 'expenses' | 'tax', id: string, patch: Partial<ChartConfig>) => void;
|
||||
removeLedgerChart: (tab: 'work' | 'payments' | 'expenses' | 'tax', id: string) => void;
|
||||
setPageTiles: (page: 'work' | 'payments' | 'expenses' | 'tax', tiles: LedgerTile[] | TaxTile[]) => void;
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────────────────
|
||||
setTheme: (theme: ThemeName, mode: ThemeMode) => void;
|
||||
setDefaultRate: (rate: number) => void;
|
||||
|
||||
// ─── File import/export ───────────────────────────────────────────────────
|
||||
/** Export a backup. Without a password the file is unencrypted plaintext. */
|
||||
exportFile: (password?: string) => Promise<void>;
|
||||
/** Import a backup. Password only required if the file is encrypted. */
|
||||
importFile: (file: File, password?: string) => Promise<void>;
|
||||
|
||||
// ─── Persistence ──────────────────────────────────────────────────────────
|
||||
persist: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ─── Implementation ──────────────────────────────────────────────────────────
|
||||
|
||||
export const useAppStore = create<AppStore>((set, get) => {
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const schedulePersist = () => {
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(() => get().persist(), 400);
|
||||
};
|
||||
|
||||
const mutate = (fn: (d: AppData) => void) => {
|
||||
set((s) => {
|
||||
const next = structuredClone(s.data);
|
||||
fn(next);
|
||||
next.version += 1;
|
||||
return { data: next };
|
||||
});
|
||||
schedulePersist();
|
||||
};
|
||||
|
||||
return {
|
||||
data: defaultData(),
|
||||
vault: null,
|
||||
ready: false,
|
||||
saving: false,
|
||||
lastSaveError: null,
|
||||
|
||||
// ─── Init ───────────────────────────────────────────────────────────────
|
||||
|
||||
init: async () => {
|
||||
const vault = new Vault({ mode: 'cookie', username: '__anon__', password: getDeviceKey() });
|
||||
let data: AppData;
|
||||
try {
|
||||
data = (await vault.load()) ?? defaultData();
|
||||
} catch {
|
||||
// Device key mismatch (localStorage cleared while cookies persisted) — start fresh
|
||||
data = defaultData();
|
||||
await vault.save(data);
|
||||
}
|
||||
// Migrate: add per-ledger chart arrays if missing
|
||||
const dd = defaultDashboard();
|
||||
if (!data.dashboard.workCharts) data.dashboard.workCharts = dd.workCharts;
|
||||
if (!data.dashboard.paymentsCharts) data.dashboard.paymentsCharts = dd.paymentsCharts;
|
||||
if (!data.dashboard.expensesCharts) data.dashboard.expensesCharts = dd.expensesCharts;
|
||||
if (!data.dashboard.taxCharts) data.dashboard.taxCharts = dd.taxCharts;
|
||||
// Add workValue to the main dashboard chart if it's missing
|
||||
const mainChart = data.dashboard.charts[0];
|
||||
if (mainChart && !mainChart.metrics.includes('workValue')) {
|
||||
mainChart.metrics = ['workValue', ...mainChart.metrics];
|
||||
}
|
||||
// Migrate: add default rolling avg window to existing work charts that were never configured
|
||||
for (const c of data.dashboard.workCharts) {
|
||||
if (c.rollingAvgWindow === undefined) c.rollingAvgWindow = 10;
|
||||
}
|
||||
// Migrate: add recurringExpenses array if missing
|
||||
if (!data.recurringExpenses) data.recurringExpenses = [];
|
||||
// Migrate: add per-page tile configs if missing
|
||||
if (!data.dashboard.workTiles) data.dashboard.workTiles = dd.workTiles;
|
||||
if (!data.dashboard.paymentsTiles) data.dashboard.paymentsTiles = dd.paymentsTiles;
|
||||
if (!data.dashboard.expensesTiles) data.dashboard.expensesTiles = dd.expensesTiles;
|
||||
if (!data.dashboard.taxTiles) data.dashboard.taxTiles = dd.taxTiles;
|
||||
// Migrate: expand widget set to include separated tiles if on old default
|
||||
const hasNewWidgets = data.dashboard.widgets.some((w) =>
|
||||
['ytdWorkValue', 'ytdPaymentsProj', 'ytdNetProj', 'ytdActualTax', 'taxRemainingDue'].includes(w),
|
||||
);
|
||||
if (!hasNewWidgets) data.dashboard.widgets = dd.widgets;
|
||||
set({ vault, data, ready: true });
|
||||
// Apply any pending recurring expense occurrences
|
||||
setTimeout(() => get().applyRecurringExpenses(), 0);
|
||||
},
|
||||
|
||||
// ─── Work CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
addWorkEntry: (e) => {
|
||||
const now = Date.now();
|
||||
const entry: WorkEntry = { ...e, id: uid(), createdAt: now, updatedAt: now };
|
||||
mutate((d) => d.workEntries.push(entry));
|
||||
return entry;
|
||||
},
|
||||
|
||||
updateWorkEntry: (id, patch) => {
|
||||
mutate((d) => {
|
||||
const i = d.workEntries.findIndex((w) => w.id === id);
|
||||
if (i >= 0) d.workEntries[i] = { ...d.workEntries[i], ...patch, updatedAt: Date.now() };
|
||||
});
|
||||
},
|
||||
|
||||
deleteWorkEntry: (id) => {
|
||||
mutate((d) => { d.workEntries = d.workEntries.filter((w) => w.id !== id); });
|
||||
},
|
||||
|
||||
// ─── Payment CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
addPayment: (p) => {
|
||||
const now = Date.now();
|
||||
const payment: Payment = { ...p, id: uid(), createdAt: now, updatedAt: now };
|
||||
mutate((d) => d.payments.push(payment));
|
||||
return payment;
|
||||
},
|
||||
|
||||
updatePayment: (id, patch) => {
|
||||
mutate((d) => {
|
||||
const i = d.payments.findIndex((p) => p.id === id);
|
||||
if (i >= 0) d.payments[i] = { ...d.payments[i], ...patch, updatedAt: Date.now() };
|
||||
});
|
||||
},
|
||||
|
||||
deletePayment: (id) => {
|
||||
mutate((d) => { d.payments = d.payments.filter((p) => p.id !== id); });
|
||||
},
|
||||
|
||||
// ─── Expense CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
addExpense: (e) => {
|
||||
const now = Date.now();
|
||||
const expense: Expense = { ...e, id: uid(), createdAt: now, updatedAt: now };
|
||||
mutate((d) => d.expenses.push(expense));
|
||||
return expense;
|
||||
},
|
||||
|
||||
updateExpense: (id, patch) => {
|
||||
mutate((d) => {
|
||||
const i = d.expenses.findIndex((x) => x.id === id);
|
||||
if (i >= 0) d.expenses[i] = { ...d.expenses[i], ...patch, updatedAt: Date.now() };
|
||||
});
|
||||
},
|
||||
|
||||
deleteExpense: (id) => {
|
||||
mutate((d) => { d.expenses = d.expenses.filter((x) => x.id !== id); });
|
||||
},
|
||||
|
||||
// ─── Recurring expenses ──────────────────────────────────────────────────
|
||||
|
||||
addRecurringExpense: (e) => {
|
||||
const now = Date.now();
|
||||
const rec: RecurringExpense = { ...e, id: uid(), createdAt: now, updatedAt: now };
|
||||
mutate((d) => d.recurringExpenses.push(rec));
|
||||
// Apply immediately after adding
|
||||
setTimeout(() => get().applyRecurringExpenses(), 0);
|
||||
return rec;
|
||||
},
|
||||
|
||||
updateRecurringExpense: (id, patch) => {
|
||||
mutate((d) => {
|
||||
const i = d.recurringExpenses.findIndex((r) => r.id === id);
|
||||
if (i >= 0) d.recurringExpenses[i] = { ...d.recurringExpenses[i], ...patch, updatedAt: Date.now() };
|
||||
});
|
||||
setTimeout(() => get().applyRecurringExpenses(), 0);
|
||||
},
|
||||
|
||||
deleteRecurringExpense: (id) => {
|
||||
mutate((d) => { d.recurringExpenses = d.recurringExpenses.filter((r) => r.id !== id); });
|
||||
},
|
||||
|
||||
applyRecurringExpenses: () => {
|
||||
const { data } = get();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const toAdd: Expense[] = [];
|
||||
|
||||
for (const re of data.recurringExpenses) {
|
||||
const occurrences = generateOccurrences(re, today);
|
||||
const existing = new Set(
|
||||
data.expenses.filter((e) => e.recurringSourceId === re.id).map((e) => e.date),
|
||||
);
|
||||
for (const date of occurrences) {
|
||||
if (!existing.has(date)) {
|
||||
const now = Date.now();
|
||||
toAdd.push({
|
||||
id: uid(), date,
|
||||
amount: re.amount, description: re.description,
|
||||
deductible: re.deductible, category: re.category,
|
||||
recurringSourceId: re.id, createdAt: now, updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
mutate((d) => { d.expenses.push(...toAdd); });
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Tax inputs ─────────────────────────────────────────────────────────
|
||||
|
||||
setTaxInputs: (year, inputs) => {
|
||||
mutate((d) => {
|
||||
const existing: Partial<TaxInputs> = d.taxInputs[year] ?? {};
|
||||
d.taxInputs[year] = {
|
||||
...existing,
|
||||
...inputs,
|
||||
taxYear: year,
|
||||
filingStatus: inputs.filingStatus ?? existing.filingStatus ?? 'single',
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addTaxPayment: (year, p) => {
|
||||
const payment: TaxPaymentRecord = { ...p, id: uid() };
|
||||
mutate((d) => {
|
||||
const existing = d.taxInputs[year] ?? { taxYear: year, filingStatus: 'single' as const };
|
||||
d.taxInputs[year] = { ...existing, taxPayments: [...(existing.taxPayments ?? []), payment] };
|
||||
});
|
||||
},
|
||||
|
||||
deleteTaxPayment: (year, id) => {
|
||||
mutate((d) => {
|
||||
const t = d.taxInputs[year];
|
||||
if (t) t.taxPayments = (t.taxPayments ?? []).filter((p) => p.id !== id);
|
||||
});
|
||||
},
|
||||
|
||||
// ─── Dashboard ──────────────────────────────────────────────────────────
|
||||
|
||||
addChart: (c) => {
|
||||
mutate((d) => {
|
||||
d.dashboard.charts.push({
|
||||
id: uid(),
|
||||
type: 'line',
|
||||
metrics: ['payments'],
|
||||
granularity: 'day',
|
||||
rangeStart: null,
|
||||
rangeEnd: null,
|
||||
yMin: null,
|
||||
yMax: null,
|
||||
...c,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
updateChart: (id, patch) => {
|
||||
mutate((d) => {
|
||||
const i = d.dashboard.charts.findIndex((c) => c.id === id);
|
||||
if (i >= 0) d.dashboard.charts[i] = { ...d.dashboard.charts[i], ...patch };
|
||||
});
|
||||
},
|
||||
|
||||
removeChart: (id) => {
|
||||
mutate((d) => { d.dashboard.charts = d.dashboard.charts.filter((c) => c.id !== id); });
|
||||
},
|
||||
|
||||
setDashboardWidgets: (w) => {
|
||||
mutate((d) => { d.dashboard.widgets = w; });
|
||||
},
|
||||
|
||||
addLedgerChart: (tab, c) => {
|
||||
const key = `${tab}Charts` as 'workCharts' | 'paymentsCharts' | 'expensesCharts' | 'taxCharts';
|
||||
const metric = tab === 'work' ? 'workValue' : tab === 'payments' ? 'payments' : 'expenses';
|
||||
mutate((d) => {
|
||||
d.dashboard[key].push({
|
||||
id: uid(),
|
||||
type: 'area',
|
||||
metrics: [metric],
|
||||
granularity: 'day',
|
||||
rangeStart: null,
|
||||
rangeEnd: null,
|
||||
yMin: null,
|
||||
yMax: null,
|
||||
...c,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
updateLedgerChart: (tab, id, patch) => {
|
||||
const key = `${tab}Charts` as 'workCharts' | 'paymentsCharts' | 'expensesCharts' | 'taxCharts';
|
||||
mutate((d) => {
|
||||
const arr = d.dashboard[key];
|
||||
const i = arr.findIndex((c) => c.id === id);
|
||||
if (i >= 0) arr[i] = { ...arr[i], ...patch };
|
||||
});
|
||||
},
|
||||
|
||||
removeLedgerChart: (tab, id) => {
|
||||
const key = `${tab}Charts` as 'workCharts' | 'paymentsCharts' | 'expensesCharts' | 'taxCharts';
|
||||
mutate((d) => { d.dashboard[key] = d.dashboard[key].filter((c) => c.id !== id); });
|
||||
},
|
||||
|
||||
setPageTiles: (page, tiles) => {
|
||||
const key = `${page}Tiles` as 'workTiles' | 'paymentsTiles' | 'expensesTiles' | 'taxTiles';
|
||||
mutate((d) => { (d.dashboard[key] as typeof tiles) = tiles; });
|
||||
},
|
||||
|
||||
// ─── Settings ───────────────────────────────────────────────────────────
|
||||
|
||||
setTheme: (theme, mode) => {
|
||||
mutate((d) => { d.settings.theme = theme; d.settings.mode = mode; });
|
||||
},
|
||||
|
||||
setDefaultRate: (rate) => {
|
||||
mutate((d) => { d.settings.defaultRate = rate; });
|
||||
},
|
||||
|
||||
// ─── File import/export ─────────────────────────────────────────────────
|
||||
|
||||
exportFile: async (password) => {
|
||||
const { vault, data } = get();
|
||||
if (!vault) return;
|
||||
await vault.exportToFile(data, password);
|
||||
},
|
||||
|
||||
importFile: async (file, password) => {
|
||||
const content = await file.text();
|
||||
const data = await deserializeT99(content, password);
|
||||
set({ data });
|
||||
schedulePersist();
|
||||
},
|
||||
|
||||
// ─── Persist ────────────────────────────────────────────────────────────
|
||||
|
||||
persist: async () => {
|
||||
const { vault, data } = get();
|
||||
if (!vault) return;
|
||||
set({ saving: true, lastSaveError: null });
|
||||
try {
|
||||
await vault.save(data);
|
||||
set({ saving: false });
|
||||
} catch (err) {
|
||||
set({ saving: false, lastSaveError: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
241
src/store/timerStore.ts
Normal file
241
src/store/timerStore.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* Timer Store — live work clock with crash recovery.
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* State persists to a dedicated cookie on every tick (heartbeat).
|
||||
* If the page loads and finds running=true with a stale heartbeat,
|
||||
* we assume a crash/close and surface a recovery banner.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { TimerState, TimerSplit, CrashRecovery } from '@/types';
|
||||
import { uid } from '@/lib/id';
|
||||
|
||||
const COOKIE_KEY = 't99_timer';
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 1 week
|
||||
const HEARTBEAT_STALE_MS = 5000; // >5s since last heartbeat → crash
|
||||
|
||||
interface TimerStore extends TimerState {
|
||||
/** Milliseconds on the live clock RIGHT NOW */
|
||||
elapsedMs: number;
|
||||
/** Crash-recovery banner payload (null = no crash detected) */
|
||||
crashRecovery: CrashRecovery | null;
|
||||
|
||||
// Actions
|
||||
start: () => void;
|
||||
pause: () => void;
|
||||
split: (label?: string) => TimerSplit;
|
||||
reset: () => void;
|
||||
setRate: (rate: number) => void;
|
||||
updateSplit: (id: string, patch: Partial<TimerSplit>) => void;
|
||||
deleteSplit: (id: string) => void;
|
||||
markRecorded: (id: string, workEntryId: string) => void;
|
||||
dismissCrashBanner: () => void;
|
||||
/** Subtract the crash gap from the current clock */
|
||||
subtractCrashGap: () => void;
|
||||
|
||||
// Internals
|
||||
_tick: () => void;
|
||||
_persistCookie: () => void;
|
||||
_restoreFromCookie: () => void;
|
||||
}
|
||||
|
||||
const initialTimer = (defaultRate: number): TimerState => ({
|
||||
currentRate: defaultRate,
|
||||
running: false,
|
||||
runStartedAt: null,
|
||||
accumulatedMs: 0,
|
||||
splits: [],
|
||||
lastHeartbeat: Date.now(),
|
||||
});
|
||||
|
||||
export const useTimerStore = create<TimerStore>((set, get) => {
|
||||
// Tick loop — updates elapsed display & writes heartbeat cookie
|
||||
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
||||
const startTicking = () => {
|
||||
if (tickHandle) return;
|
||||
tickHandle = setInterval(() => get()._tick(), 1000);
|
||||
};
|
||||
const stopTicking = () => {
|
||||
if (tickHandle) {
|
||||
clearInterval(tickHandle);
|
||||
tickHandle = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...initialTimer(50),
|
||||
elapsedMs: 0,
|
||||
crashRecovery: null,
|
||||
|
||||
start: () => {
|
||||
const s = get();
|
||||
if (s.running) return;
|
||||
set({
|
||||
running: true,
|
||||
runStartedAt: Date.now(),
|
||||
});
|
||||
startTicking();
|
||||
get()._persistCookie();
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
const s = get();
|
||||
if (!s.running || s.runStartedAt == null) return;
|
||||
const segment = Date.now() - s.runStartedAt;
|
||||
set({
|
||||
running: false,
|
||||
runStartedAt: null,
|
||||
accumulatedMs: s.accumulatedMs + segment,
|
||||
elapsedMs: s.accumulatedMs + segment,
|
||||
});
|
||||
stopTicking();
|
||||
get()._persistCookie();
|
||||
},
|
||||
|
||||
split: (label) => {
|
||||
const s = get();
|
||||
// Compute elapsed up to this instant
|
||||
const now = Date.now();
|
||||
const live = s.running && s.runStartedAt != null ? now - s.runStartedAt : 0;
|
||||
const splitMs = s.accumulatedMs + live;
|
||||
const split: TimerSplit = {
|
||||
id: uid(),
|
||||
startedAt: s.runStartedAt ?? now - splitMs,
|
||||
elapsedMs: splitMs,
|
||||
rate: s.currentRate,
|
||||
label,
|
||||
recorded: false,
|
||||
};
|
||||
// Reset live clock but keep running
|
||||
set({
|
||||
splits: [...s.splits, split],
|
||||
accumulatedMs: 0,
|
||||
runStartedAt: s.running ? now : null,
|
||||
elapsedMs: 0,
|
||||
});
|
||||
get()._persistCookie();
|
||||
return split;
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
const rate = get().currentRate;
|
||||
stopTicking();
|
||||
set({ ...initialTimer(rate), elapsedMs: 0, crashRecovery: null });
|
||||
get()._persistCookie();
|
||||
},
|
||||
|
||||
setRate: (rate) => {
|
||||
// Only affects the LIVE clock; existing splits keep their own rate.
|
||||
set({ currentRate: rate });
|
||||
get()._persistCookie();
|
||||
},
|
||||
|
||||
updateSplit: (id, patch) => {
|
||||
set((s) => ({
|
||||
splits: s.splits.map((sp) =>
|
||||
sp.id === id ? { ...sp, ...patch } : sp,
|
||||
),
|
||||
}));
|
||||
get()._persistCookie();
|
||||
},
|
||||
|
||||
deleteSplit: (id) => {
|
||||
set((s) => ({ splits: s.splits.filter((sp) => sp.id !== id) }));
|
||||
get()._persistCookie();
|
||||
},
|
||||
|
||||
markRecorded: (id, workEntryId) => {
|
||||
set((s) => ({
|
||||
splits: s.splits.map((sp) =>
|
||||
sp.id === id
|
||||
? { ...sp, recorded: true, recordedWorkEntryId: workEntryId }
|
||||
: sp,
|
||||
),
|
||||
}));
|
||||
get()._persistCookie();
|
||||
},
|
||||
|
||||
dismissCrashBanner: () => {
|
||||
set({ crashRecovery: null });
|
||||
},
|
||||
|
||||
subtractCrashGap: () => {
|
||||
const { crashRecovery, accumulatedMs } = get();
|
||||
if (!crashRecovery) return;
|
||||
set({
|
||||
accumulatedMs: Math.max(0, accumulatedMs - crashRecovery.gapMs),
|
||||
crashRecovery: null,
|
||||
});
|
||||
get()._tick(); // refresh elapsed display
|
||||
get()._persistCookie();
|
||||
},
|
||||
|
||||
// ─── Internals ──────────────────────────────────────────────────────────
|
||||
|
||||
_tick: () => {
|
||||
const s = get();
|
||||
const live = s.running && s.runStartedAt != null ? Date.now() - s.runStartedAt : 0;
|
||||
set({ elapsedMs: s.accumulatedMs + live, lastHeartbeat: Date.now() });
|
||||
get()._persistCookie();
|
||||
},
|
||||
|
||||
_persistCookie: () => {
|
||||
const s = get();
|
||||
const snapshot: TimerState = {
|
||||
currentRate: s.currentRate,
|
||||
running: s.running,
|
||||
runStartedAt: s.runStartedAt,
|
||||
accumulatedMs: s.accumulatedMs,
|
||||
splits: s.splits,
|
||||
lastHeartbeat: s.lastHeartbeat,
|
||||
};
|
||||
try {
|
||||
const json = JSON.stringify(snapshot);
|
||||
document.cookie = `${COOKIE_KEY}=${encodeURIComponent(json)}; max-age=${COOKIE_MAX_AGE}; path=/; SameSite=Strict`;
|
||||
} catch {
|
||||
/* cookie too large — splits table probably huge; degrade silently */
|
||||
}
|
||||
},
|
||||
|
||||
_restoreFromCookie: () => {
|
||||
const match = document.cookie.match(
|
||||
new RegExp(`(?:^|; )${COOKIE_KEY}=([^;]*)`),
|
||||
);
|
||||
if (!match) return;
|
||||
try {
|
||||
const snap: TimerState = JSON.parse(decodeURIComponent(match[1]));
|
||||
const now = Date.now();
|
||||
|
||||
// Detect crash: was running, heartbeat is stale
|
||||
let crashRecovery: CrashRecovery | null = null;
|
||||
if (snap.running && now - snap.lastHeartbeat > HEARTBEAT_STALE_MS) {
|
||||
crashRecovery = {
|
||||
crashTime: snap.lastHeartbeat,
|
||||
reloadTime: now,
|
||||
gapMs: now - snap.lastHeartbeat,
|
||||
};
|
||||
}
|
||||
|
||||
// Restore state. If it was running, we keep it running —
|
||||
// runStartedAt is the ORIGINAL epoch, so elapsed naturally
|
||||
// includes the gap (user can subtract it via the banner).
|
||||
set({
|
||||
...snap,
|
||||
elapsedMs: snap.running && snap.runStartedAt != null
|
||||
? snap.accumulatedMs + (now - snap.runStartedAt)
|
||||
: snap.accumulatedMs,
|
||||
crashRecovery,
|
||||
});
|
||||
|
||||
if (snap.running) startTicking();
|
||||
} catch {
|
||||
/* corrupted cookie — ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Restore on module load
|
||||
if (typeof document !== 'undefined') {
|
||||
useTimerStore.getState()._restoreFromCookie();
|
||||
}
|
||||
34
src/themes/ThemeProvider.tsx
Normal file
34
src/themes/ThemeProvider.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
import type { ThemeName, ThemeMode } from '@/types';
|
||||
|
||||
export const THEME_NAMES: Array<{ id: ThemeName; label: string }> = [
|
||||
{ id: 'standard', label: 'Standard' },
|
||||
{ id: 'sakura', label: 'Sakura' },
|
||||
{ id: 'pumpkin', label: 'Pumpkin' },
|
||||
{ id: 'fall', label: 'Fall' },
|
||||
{ id: 'aqua', label: 'Aqua' },
|
||||
{ id: 'lavender', label: 'Lavender' },
|
||||
{ id: 'comic', label: 'Comic' },
|
||||
{ id: 'manga', label: 'Manga' },
|
||||
{ id: 'highcontrast', label: 'High Contrast' },
|
||||
{ id: 'cyberpunk', label: 'Cyberpunk' },
|
||||
];
|
||||
|
||||
/** Applies the current theme to <html> via data attributes. */
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const theme = useAppStore((s) => s.data.settings.theme);
|
||||
const mode = useAppStore((s) => s.data.settings.mode);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme, mode);
|
||||
}, [theme, mode]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function applyTheme(theme: ThemeName, mode: ThemeMode) {
|
||||
const html = document.documentElement;
|
||||
html.setAttribute('data-theme', theme);
|
||||
html.setAttribute('data-mode', mode);
|
||||
}
|
||||
894
src/themes/global.css
Normal file
894
src/themes/global.css
Normal file
|
|
@ -0,0 +1,894 @@
|
|||
@import './themes.css';
|
||||
|
||||
/* ============================================================================
|
||||
GLOBAL STYLES
|
||||
========================================================================== */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ─── Layout primitives ───────────────────────────────────────────────────── */
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
height: 56px;
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.app-header .logo {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-nav a {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg-muted);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.app-nav a:hover {
|
||||
background: var(--bg-elev-2);
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
}
|
||||
.app-nav a.active {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.app-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ─── Resizable split layout ─────────────────────────────────────────────── */
|
||||
|
||||
.rsplit {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.rsplit-left {
|
||||
flex: 0 0 var(--split-pct, 50%);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rsplit-right {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Drag divider */
|
||||
.rsplit-divider {
|
||||
flex: 0 0 12px;
|
||||
align-self: stretch;
|
||||
cursor: col-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background var(--transition);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.rsplit-divider:hover,
|
||||
.rsplit--dragging .rsplit-divider { background: var(--accent-muted); }
|
||||
|
||||
.rsplit-divider-handle {
|
||||
width: 4px;
|
||||
height: 48px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.rsplit-divider:hover .rsplit-divider-handle,
|
||||
.rsplit--dragging .rsplit-divider-handle { background: var(--accent); }
|
||||
|
||||
/* Prevent text selection while dragging */
|
||||
.rsplit--dragging { user-select: none; }
|
||||
|
||||
/* Mobile toggle button — hidden on desktop */
|
||||
.rsplit-toggle { display: none; }
|
||||
|
||||
/* ─── Mobile: vertical stack, charts collapsible above data ─────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.rsplit { flex-direction: column; gap: 0; }
|
||||
|
||||
/* Toggle sits at the very top */
|
||||
.rsplit-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
order: 1;
|
||||
width: 100%;
|
||||
padding: 10px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--fg-muted);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.rsplit-toggle:hover { color: var(--fg); background: var(--bg-elev-2); }
|
||||
|
||||
/* Charts — above data, hidden by default */
|
||||
.rsplit-right {
|
||||
order: 2;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
display: none;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.rsplit-right--open { display: flex; }
|
||||
|
||||
/* Divider hidden on mobile */
|
||||
.rsplit-divider { display: none; }
|
||||
|
||||
/* Data — below charts */
|
||||
.rsplit-left {
|
||||
order: 3;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Cards ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-title { font-size: 15px; font-weight: 600; }
|
||||
|
||||
/* ─── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { background: var(--bg-elev-2); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(1.1); }
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
.btn-ghost:hover { background: var(--bg-elev-2); }
|
||||
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn-lg { padding: 12px 20px; font-size: 15px; }
|
||||
.btn-icon { padding: 6px; width: 32px; height: 32px; }
|
||||
|
||||
.btn-group {
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
}
|
||||
.btn-group .btn {
|
||||
border-radius: 0;
|
||||
margin-left: -1px;
|
||||
}
|
||||
.btn-group .btn:first-child { border-radius: var(--radius-sm) 0 0 var(--radius-sm); margin-left: 0; }
|
||||
.btn-group .btn:last-child { border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }
|
||||
.btn-group .btn.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); z-index: 1; }
|
||||
|
||||
/* ─── Payer autocomplete dropdown ────────────────────────────────────────── */
|
||||
|
||||
.payer-wrap { position: relative; }
|
||||
.payer-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.payer-option {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: var(--fg);
|
||||
}
|
||||
.payer-option:hover, .payer-option.active {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ─── Forms ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.input, .select, .textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.input:focus, .select:focus, .textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-muted);
|
||||
}
|
||||
|
||||
.input-inline {
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.field label { font-size: 12px; color: var(--fg-muted); font-weight: 500; }
|
||||
.field-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; }
|
||||
|
||||
/* Highlighted prompt field for missing tax data */
|
||||
.field-prompt {
|
||||
position: relative;
|
||||
border: 2px solid var(--warning);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, var(--warning) 8%, transparent);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
.field-prompt .prompt-reason {
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { border-color: var(--warning); }
|
||||
50% { border-color: color-mix(in srgb, var(--warning) 50%, transparent); }
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ─── Tables & spreadsheets ──────────────────────────────────────────────── */
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-muted);
|
||||
border-bottom: 2px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.data-table td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.data-table tbody tr:hover { background: var(--bg-elev-2); }
|
||||
.data-table .num { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.data-table tfoot td { font-weight: 600; border-top: 2px solid var(--border); border-bottom: none; }
|
||||
|
||||
/* Hierarchical spreadsheet rows */
|
||||
.hier-row { user-select: none; }
|
||||
.hier-row.level-year { font-weight: 700; background: var(--bg-elev-2); }
|
||||
.hier-row.level-month { font-weight: 600; }
|
||||
.hier-row.level-day { font-weight: 500; color: var(--fg-muted); }
|
||||
.hier-row.level-item { font-weight: 400; }
|
||||
|
||||
/* Clickable label — label-click focuses/collapses to that category */
|
||||
.hier-label { cursor: pointer; }
|
||||
.hier-label:hover { color: var(--accent); }
|
||||
|
||||
/* Item rows that open a detail popup on click */
|
||||
.hier-row--clickable { cursor: pointer; }
|
||||
.hier-row--clickable:hover { background: var(--accent-muted) !important; }
|
||||
|
||||
/* Day-row "+" add button — hidden until row is hovered */
|
||||
.hier-add-btn { opacity: 0; transition: opacity var(--transition); }
|
||||
.hier-row:hover .hier-add-btn { opacity: 1; }
|
||||
|
||||
.hier-toggle {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
margin-right: 4px;
|
||||
color: var(--fg-muted);
|
||||
transition: transform var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
.hier-toggle:empty { cursor: default; }
|
||||
.hier-toggle.expanded { transform: rotate(90deg); }
|
||||
|
||||
/* Override table cell left padding — hier-cell handles it via flex layout */
|
||||
.hier-row td:first-child { padding-left: 0; }
|
||||
|
||||
/* Flex container: [indent slots…] [toggle] [label] */
|
||||
.hier-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* Each indent slot is a fixed-width guide column */
|
||||
.hier-indent {
|
||||
flex: 0 0 20px;
|
||||
align-self: stretch;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Full vertical guide line */
|
||||
.hier-indent::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--fg-muted);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* Horizontal branch connector at midpoint */
|
||||
.hier-indent.branch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--fg-muted);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* ─── Stats widgets ───────────────────────────────────────────────────────── */
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
}
|
||||
.stat-card .stat-label { font-size: 11px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stat-card .stat-value { font-size: 24px; font-weight: 700; font-family: var(--font-mono); margin-top: 4px; }
|
||||
.stat-card .stat-sub { font-size: 12px; color: var(--fg-muted); margin-top: 2px; }
|
||||
.stat-card.positive .stat-value { color: var(--success); }
|
||||
.stat-card.negative .stat-value { color: var(--danger); }
|
||||
|
||||
/* ─── Timer ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.timer-display {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 56px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
letter-spacing: 2px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.timer-earned {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.timer-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.crash-banner {
|
||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||
border: 2px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
.crash-banner .crash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 12px;
|
||||
margin: 8px 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.crash-banner .crash-actions { display: flex; gap: 8px; margin-top: 10px; }
|
||||
|
||||
.recorded-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ─── Modals ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-title { font-size: 16px; margin-bottom: 16px; }
|
||||
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
|
||||
/* ─── Tabs ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
gap: 4px;
|
||||
}
|
||||
.tab {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--fg-muted);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab:hover { color: var(--fg); }
|
||||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
/* ─── Inline spreadsheet ─────────────────────────────────────────────────── */
|
||||
|
||||
.ss-rangebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 0 8px;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ss-rangebar label { color: var(--fg-muted); font-size: 12px; }
|
||||
.ss-rangebar-input { height: 32px; width: 160px; }
|
||||
.ss-rangebar-summary { margin-left: auto; color: var(--fg-muted); font-size: 12px; }
|
||||
|
||||
.ss-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ss-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ss-table th {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 2px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ss-table th.ss-right { text-align: right; }
|
||||
.ss-table td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
vertical-align: middle;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
/* Display cell — click to enter edit mode */
|
||||
.ss-cell {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 7px 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ss-cell:hover { background: var(--accent-muted); }
|
||||
.ss-cell.ss-right { text-align: right; }
|
||||
.ss-empty { color: var(--fg-muted); }
|
||||
|
||||
/* Input that fills the cell when editing */
|
||||
.ss-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-elev));
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ss-input.ss-right { text-align: right; }
|
||||
select.ss-input { cursor: pointer; }
|
||||
|
||||
.ss-value { padding: 7px 10px; }
|
||||
.ss-actions { padding: 0 4px; text-align: center; white-space: nowrap; }
|
||||
|
||||
/* Existing data rows */
|
||||
.ss-row:hover { background: var(--bg-elev-2); }
|
||||
.ss-row:hover .ss-cell:hover { background: var(--accent-muted); }
|
||||
|
||||
/* Add-new draft row */
|
||||
.ss-draft td { background: color-mix(in srgb, var(--success) 6%, var(--bg-elev)); }
|
||||
.ss-draft .ss-input {
|
||||
background: color-mix(in srgb, var(--success) 10%, var(--bg-elev));
|
||||
outline-color: var(--success);
|
||||
}
|
||||
|
||||
/* Footer total row */
|
||||
.ss-total td {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
padding: 7px 10px;
|
||||
border-top: 2px solid var(--border);
|
||||
color: var(--fg-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.ss-total .ss-right { text-align: right; color: var(--fg); font-family: var(--font-mono); }
|
||||
|
||||
/* ─── Chart grid ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/* minmax(min(400px,100%),1fr) prevents overflow when viewport < 400px */
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(400px, 100%), 1fr));
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ─── Footer ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.app-footer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 10px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.app-footer-sep { opacity: 0.4; }
|
||||
|
||||
/* ─── Ko-fi header button & modal ────────────────────────────────────────── */
|
||||
|
||||
.kofi-header-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
|
||||
.kofi-header-btn:hover { filter: brightness(1.12); }
|
||||
|
||||
.kofi-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.kofi-modal {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
/* tall enough for the 712px iframe + 48px header without scrolling */
|
||||
height: min(760px, 95vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.kofi-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kofi-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent-fg);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.kofi-modal-close:hover { opacity: 1; }
|
||||
|
||||
.kofi-modal iframe {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* In dark mode, invert the iframe content then rotate hue back so colours
|
||||
stay natural while the bright white background becomes dark.
|
||||
Background is set to #fff so it inverts to near-black, matching the theme. */
|
||||
[data-mode='dark'] .kofi-modal iframe {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
|
||||
/* ─── Hamburger / mobile nav ─────────────────────────────────────────────── */
|
||||
|
||||
.hamburger-btn {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 101;
|
||||
}
|
||||
.hamburger-btn:hover { background: var(--bg-elev-2); }
|
||||
|
||||
/* Overlay catches outside-clicks to close the menu */
|
||||
.nav-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ─── Responsive ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Tablets (≤900px) — switch to hamburger nav */
|
||||
@media (max-width: 900px) {
|
||||
.app-body { padding: 16px; }
|
||||
.timer-display { font-size: 44px; }
|
||||
.app-header { position: relative; }
|
||||
.hamburger-btn { display: inline-flex; }
|
||||
.header-status { margin-left: auto; }
|
||||
.kofi-header-btn { display: none; }
|
||||
.app-nav {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
padding: 8px;
|
||||
z-index: 100;
|
||||
}
|
||||
.app-nav.open { display: flex; }
|
||||
.app-nav a { padding: 10px 14px; border-radius: var(--radius-sm); }
|
||||
}
|
||||
|
||||
/* Small phones (≤640px) — extra compact spacing */
|
||||
@media (max-width: 640px) {
|
||||
.app-body { padding: 10px; }
|
||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.stat-card .stat-value { font-size: 20px; }
|
||||
.timer-display { font-size: 36px; }
|
||||
.timer-earned { font-size: 20px; }
|
||||
.field-row { grid-template-columns: 1fr; }
|
||||
.data-table { font-size: 12px; }
|
||||
.data-table th, .data-table td { padding: 6px 8px; }
|
||||
.modal { padding: 16px; }
|
||||
.col-hide-sm { display: none; }
|
||||
}
|
||||
|
||||
/* 4K TV (≥3840px) — only true 4K displays, no margin centering */
|
||||
@media (min-width: 3840px) {
|
||||
body { font-size: 16px; }
|
||||
.app-header { height: 72px; padding: 0 40px; }
|
||||
.app-body { padding: 40px; }
|
||||
.stat-card .stat-value { font-size: 32px; }
|
||||
.timer-display { font-size: 80px; }
|
||||
}
|
||||
|
||||
/* ─── Utilities ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.flex { display: flex; }
|
||||
.flex-col { display: flex; flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-1 { gap: 4px; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mb-2 { margin-bottom: 8px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.text-muted { color: var(--fg-muted); }
|
||||
.text-success { color: var(--success); }
|
||||
.text-danger { color: var(--danger); }
|
||||
.text-sm { font-size: 12px; }
|
||||
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.scroll-y { overflow-y: auto; }
|
||||
.full-height { height: 100%; }
|
||||
361
src/themes/themes.css
Normal file
361
src/themes/themes.css
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
/* ============================================================================
|
||||
THEME SYSTEM — 10 themes × 2 modes = 20 palettes
|
||||
Applied via: <html data-theme="sakura" data-mode="dark">
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
--font-body: 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-display: var(--font-body);
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
--transition: 150ms ease;
|
||||
}
|
||||
|
||||
/* ─── STANDARD ──────────────────────────────────────────────────────────── */
|
||||
[data-theme='standard'][data-mode='light'] {
|
||||
--bg: #fafafa;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-elev-2: #f3f4f6;
|
||||
--fg: #111827;
|
||||
--fg-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--accent: #2563eb;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-muted: #dbeafe;
|
||||
--success: #059669;
|
||||
--warning: #d97706;
|
||||
--danger: #dc2626;
|
||||
--chart-1: #2563eb; --chart-2: #059669; --chart-3: #d97706; --chart-4: #dc2626; --chart-5: #7c3aed;
|
||||
}
|
||||
[data-theme='standard'][data-mode='dark'] {
|
||||
--bg: #0f1419;
|
||||
--bg-elev: #1a1f26;
|
||||
--bg-elev-2: #252b33;
|
||||
--fg: #e5e7eb;
|
||||
--fg-muted: #9ca3af;
|
||||
--border: #2d333b;
|
||||
--accent: #3b82f6;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-muted: #1e3a5f;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--chart-1: #60a5fa; --chart-2: #34d399; --chart-3: #fbbf24; --chart-4: #f87171; --chart-5: #a78bfa;
|
||||
}
|
||||
|
||||
/* ─── SAKURA (cherry blossom pink, soft) ────────────────────────────────── */
|
||||
[data-theme='sakura'] { --font-display: 'Noto Serif JP', serif; }
|
||||
[data-theme='sakura'][data-mode='light'] {
|
||||
--bg: #fef7f9;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-elev-2: #fce8ef;
|
||||
--fg: #4a2b3a;
|
||||
--fg-muted: #a87490;
|
||||
--border: #f5d0de;
|
||||
--accent: #e56b9f;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-muted: #fadce8;
|
||||
--success: #5fb878;
|
||||
--warning: #d48806;
|
||||
--danger: #d9506c;
|
||||
--chart-1: #e56b9f; --chart-2: #5fb878; --chart-3: #f5a5c4; --chart-4: #d9506c; --chart-5: #ba8ab6;
|
||||
}
|
||||
[data-theme='sakura'][data-mode='dark'] {
|
||||
--bg: #1f1519;
|
||||
--bg-elev: #2b1e24;
|
||||
--bg-elev-2: #3a2a32;
|
||||
--fg: #f5e6eb;
|
||||
--fg-muted: #bf9aa9;
|
||||
--border: #4d3540;
|
||||
--accent: #f08cb3;
|
||||
--accent-fg: #1f1519;
|
||||
--accent-muted: #4d2638;
|
||||
--success: #6dd28f;
|
||||
--warning: #e0a82e;
|
||||
--danger: #f06a85;
|
||||
--chart-1: #f08cb3; --chart-2: #6dd28f; --chart-3: #f5bdd2; --chart-4: #f06a85; --chart-5: #c9a3c6;
|
||||
}
|
||||
|
||||
/* ─── PUMPKIN (autumn orange, cozy) ─────────────────────────────────────── */
|
||||
[data-theme='pumpkin'][data-mode='light'] {
|
||||
--bg: #fdf8f3;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-elev-2: #faedd f;
|
||||
--bg-elev-2: #faeddf;
|
||||
--fg: #3d2817;
|
||||
--fg-muted: #8a6b52;
|
||||
--border: #ead6c2;
|
||||
--accent: #d97706;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-muted: #fde7c8;
|
||||
--success: #5a8a3a;
|
||||
--warning: #b45309;
|
||||
--danger: #b91c1c;
|
||||
--chart-1: #d97706; --chart-2: #5a8a3a; --chart-3: #f0b463; --chart-4: #b91c1c; --chart-5: #a16207;
|
||||
}
|
||||
[data-theme='pumpkin'][data-mode='dark'] {
|
||||
--bg: #1a1410;
|
||||
--bg-elev: #261e17;
|
||||
--bg-elev-2: #352a1f;
|
||||
--fg: #f5ede5;
|
||||
--fg-muted: #bf9f82;
|
||||
--border: #4d3d2d;
|
||||
--accent: #f59e0b;
|
||||
--accent-fg: #1a1410;
|
||||
--accent-muted: #4d3310;
|
||||
--success: #7ab555;
|
||||
--warning: #e08b1f;
|
||||
--danger: #e05b5b;
|
||||
--chart-1: #f59e0b; --chart-2: #7ab555; --chart-3: #f5c97a; --chart-4: #e05b5b; --chart-5: #d97706;
|
||||
}
|
||||
|
||||
/* ─── FALL (deep reds, browns, golds) ───────────────────────────────────── */
|
||||
[data-theme='fall'][data-mode='light'] {
|
||||
--bg: #faf6f0;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-elev-2: #f2ebe0;
|
||||
--fg: #2d1f14;
|
||||
--fg-muted: #7d6550;
|
||||
--border: #e0d3c2;
|
||||
--accent: #8b3a2f;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-muted: #f0d5d0;
|
||||
--success: #4d7c3a;
|
||||
--warning: #a15f0a;
|
||||
--danger: #991b1b;
|
||||
--chart-1: #8b3a2f; --chart-2: #b88a2b; --chart-3: #4d7c3a; --chart-4: #703e20; --chart-5: #c2532d;
|
||||
}
|
||||
[data-theme='fall'][data-mode='dark'] {
|
||||
--bg: #191310;
|
||||
--bg-elev: #241c17;
|
||||
--bg-elev-2: #33261f;
|
||||
--fg: #f0e8dd;
|
||||
--fg-muted: #b39d85;
|
||||
--border: #4d3b2d;
|
||||
--accent: #c25b4d;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-muted: #52251f;
|
||||
--success: #6da052;
|
||||
--warning: #d4951f;
|
||||
--danger: #d94545;
|
||||
--chart-1: #c25b4d; --chart-2: #d4a73d; --chart-3: #6da052; --chart-4: #9c6038; --chart-5: #e07856;
|
||||
}
|
||||
|
||||
/* ─── AQUA (ocean teals & blues) ────────────────────────────────────────── */
|
||||
[data-theme='aqua'][data-mode='light'] {
|
||||
--bg: #f0fafb;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-elev-2: #daf2f5;
|
||||
--fg: #0f3640;
|
||||
--fg-muted: #5a8590;
|
||||
--border: #c2e5ea;
|
||||
--accent: #0891b2;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-muted: #cef4fa;
|
||||
--success: #059669;
|
||||
--warning: #c2850f;
|
||||
--danger: #c23b3b;
|
||||
--chart-1: #0891b2; --chart-2: #059669; --chart-3: #0ea5e9; --chart-4: #14b8a6; --chart-5: #3b82f6;
|
||||
}
|
||||
[data-theme='aqua'][data-mode='dark'] {
|
||||
--bg: #0a1a1f;
|
||||
--bg-elev: #10262e;
|
||||
--bg-elev-2: #17353f;
|
||||
--fg: #e0f4f7;
|
||||
--fg-muted: #7fb0ba;
|
||||
--border: #1f4550;
|
||||
--accent: #22d3ee;
|
||||
--accent-fg: #0a1a1f;
|
||||
--accent-muted: #0f4050;
|
||||
--success: #2dd4bf;
|
||||
--warning: #e5b52e;
|
||||
--danger: #f06060;
|
||||
--chart-1: #22d3ee; --chart-2: #2dd4bf; --chart-3: #38bdf8; --chart-4: #5eead4; --chart-5: #60a5fa;
|
||||
}
|
||||
|
||||
/* ─── LAVENDER (soft purples) ───────────────────────────────────────────── */
|
||||
[data-theme='lavender'][data-mode='light'] {
|
||||
--bg: #faf7fd;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-elev-2: #f0eaf7;
|
||||
--fg: #2d1f42;
|
||||
--fg-muted: #7a659e;
|
||||
--border: #e0d5ed;
|
||||
--accent: #7c3aed;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-muted: #ede5fa;
|
||||
--success: #4d9e70;
|
||||
--warning: #bf8f1a;
|
||||
--danger: #c73e5d;
|
||||
--chart-1: #7c3aed; --chart-2: #a78bfa; --chart-3: #4d9e70; --chart-4: #c73e5d; --chart-5: #9d6ad9;
|
||||
}
|
||||
[data-theme='lavender'][data-mode='dark'] {
|
||||
--bg: #16121f;
|
||||
--bg-elev: #211b2e;
|
||||
--bg-elev-2: #2e2540;
|
||||
--fg: #eee8f7;
|
||||
--fg-muted: #aa95cc;
|
||||
--border: #3e3055;
|
||||
--accent: #a78bfa;
|
||||
--accent-fg: #16121f;
|
||||
--accent-muted: #332652;
|
||||
--success: #6ec98f;
|
||||
--warning: #e5b83d;
|
||||
--danger: #e35b7a;
|
||||
--chart-1: #a78bfa; --chart-2: #c4b5fd; --chart-3: #6ec98f; --chart-4: #e35b7a; --chart-5: #8b5cf6;
|
||||
}
|
||||
|
||||
/* ─── COMIC (bold primary colors, playful) ──────────────────────────────── */
|
||||
[data-theme='comic'] {
|
||||
--font-display: 'Bangers', cursive;
|
||||
--font-body: 'Comic Neue', cursive;
|
||||
--radius: 12px;
|
||||
}
|
||||
[data-theme='comic'][data-mode='light'] {
|
||||
--bg: #fffceb;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-elev-2: #fff5c2;
|
||||
--fg: #1a1a1a;
|
||||
--fg-muted: #666666;
|
||||
--border: #1a1a1a;
|
||||
--accent: #ff3b3b;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-muted: #ffd6d6;
|
||||
--success: #00a651;
|
||||
--warning: #ffa500;
|
||||
--danger: #e60000;
|
||||
--shadow: 4px 4px 0 #1a1a1a;
|
||||
--shadow-lg: 6px 6px 0 #1a1a1a;
|
||||
--chart-1: #ff3b3b; --chart-2: #00a651; --chart-3: #ffa500; --chart-4: #0066ff; --chart-5: #9933ff;
|
||||
}
|
||||
[data-theme='comic'][data-mode='dark'] {
|
||||
--bg: #1a1a2e;
|
||||
--bg-elev: #242445;
|
||||
--bg-elev-2: #2d2d5a;
|
||||
--fg: #f5f5dc;
|
||||
--fg-muted: #b0b0cc;
|
||||
--border: #f5f5dc;
|
||||
--accent: #ff5c5c;
|
||||
--accent-fg: #1a1a2e;
|
||||
--accent-muted: #52262e;
|
||||
--success: #00cc66;
|
||||
--warning: #ffb833;
|
||||
--danger: #ff3333;
|
||||
--shadow: 4px 4px 0 #f5f5dc;
|
||||
--shadow-lg: 6px 6px 0 #f5f5dc;
|
||||
--chart-1: #ff5c5c; --chart-2: #00cc66; --chart-3: #ffb833; --chart-4: #5c85ff; --chart-5: #b36bff;
|
||||
}
|
||||
|
||||
/* ─── MANGA (monochrome with screentone feel) ───────────────────────────── */
|
||||
[data-theme='manga'] {
|
||||
--font-display: 'Noto Serif JP', serif;
|
||||
--radius: 2px;
|
||||
}
|
||||
[data-theme='manga'][data-mode='light'] {
|
||||
--bg: #ffffff;
|
||||
--bg-elev: #f7f7f7;
|
||||
--bg-elev-2: #ebebeb;
|
||||
--fg: #000000;
|
||||
--fg-muted: #666666;
|
||||
--border: #000000;
|
||||
--accent: #000000;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-muted: #d0d0d0;
|
||||
--success: #2d5a2d;
|
||||
--warning: #5a5a2d;
|
||||
--danger: #5a2d2d;
|
||||
--chart-1: #000000; --chart-2: #4d4d4d; --chart-3: #808080; --chart-4: #b3b3b3; --chart-5: #262626;
|
||||
}
|
||||
[data-theme='manga'][data-mode='dark'] {
|
||||
--bg: #0a0a0a;
|
||||
--bg-elev: #1a1a1a;
|
||||
--bg-elev-2: #2a2a2a;
|
||||
--fg: #ffffff;
|
||||
--fg-muted: #aaaaaa;
|
||||
--border: #ffffff;
|
||||
--accent: #ffffff;
|
||||
--accent-fg: #000000;
|
||||
--accent-muted: #404040;
|
||||
--success: #8ab88a;
|
||||
--warning: #b8b88a;
|
||||
--danger: #b88a8a;
|
||||
--chart-1: #ffffff; --chart-2: #cccccc; --chart-3: #999999; --chart-4: #666666; --chart-5: #e6e6e6;
|
||||
}
|
||||
|
||||
/* ─── HIGH CONTRAST (accessibility) ─────────────────────────────────────── */
|
||||
[data-theme='highcontrast'] {
|
||||
--radius: 2px;
|
||||
--font-body: system-ui, sans-serif;
|
||||
}
|
||||
[data-theme='highcontrast'][data-mode='light'] {
|
||||
--bg: #ffffff;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-elev-2: #f0f0f0;
|
||||
--fg: #000000;
|
||||
--fg-muted: #333333;
|
||||
--border: #000000;
|
||||
--accent: #0000ee;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-muted: #ccccff;
|
||||
--success: #006600;
|
||||
--warning: #8a5000;
|
||||
--danger: #cc0000;
|
||||
--chart-1: #0000ee; --chart-2: #006600; --chart-3: #8a5000; --chart-4: #cc0000; --chart-5: #660099;
|
||||
}
|
||||
[data-theme='highcontrast'][data-mode='dark'] {
|
||||
--bg: #000000;
|
||||
--bg-elev: #000000;
|
||||
--bg-elev-2: #1a1a1a;
|
||||
--fg: #ffffff;
|
||||
--fg-muted: #dddddd;
|
||||
--border: #ffffff;
|
||||
--accent: #00ffff;
|
||||
--accent-fg: #000000;
|
||||
--accent-muted: #003333;
|
||||
--success: #00ff00;
|
||||
--warning: #ffff00;
|
||||
--danger: #ff4d4d;
|
||||
--chart-1: #00ffff; --chart-2: #00ff00; --chart-3: #ffff00; --chart-4: #ff4d4d; --chart-5: #ff00ff;
|
||||
}
|
||||
|
||||
/* ─── CYBERPUNK (neon magenta + cyan on black) ──────────────────────────── */
|
||||
[data-theme='cyberpunk'] {
|
||||
--font-display: 'Orbitron', sans-serif;
|
||||
--font-mono: 'Orbitron', monospace;
|
||||
--radius: 0;
|
||||
}
|
||||
[data-theme='cyberpunk'][data-mode='light'] {
|
||||
--bg: #f0ecff;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-elev-2: #e5dcff;
|
||||
--fg: #1a0f33;
|
||||
--fg-muted: #6650a3;
|
||||
--border: #b8a3ff;
|
||||
--accent: #ff00a0;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-muted: #ffccf0;
|
||||
--success: #00b894;
|
||||
--warning: #d9a000;
|
||||
--danger: #e60052;
|
||||
--chart-1: #ff00a0; --chart-2: #00d9ff; --chart-3: #ffdd00; --chart-4: #9d00ff; --chart-5: #00ffa6;
|
||||
}
|
||||
[data-theme='cyberpunk'][data-mode='dark'] {
|
||||
--bg: #0a0014;
|
||||
--bg-elev: #14051f;
|
||||
--bg-elev-2: #1f0a33;
|
||||
--fg: #f0e6ff;
|
||||
--fg-muted: #9680cc;
|
||||
--border: #3d1f66;
|
||||
--accent: #ff00d4;
|
||||
--accent-fg: #0a0014;
|
||||
--accent-muted: #4d0040;
|
||||
--success: #00ffbf;
|
||||
--warning: #ffcc00;
|
||||
--danger: #ff0055;
|
||||
--shadow: 0 0 12px rgba(255, 0, 212, 0.3);
|
||||
--shadow-lg: 0 0 24px rgba(255, 0, 212, 0.5);
|
||||
--chart-1: #ff00d4; --chart-2: #00ffff; --chart-3: #ffcc00; --chart-4: #b300ff; --chart-5: #00ffbf;
|
||||
}
|
||||
406
src/types/index.ts
Normal file
406
src/types/index.ts
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
// ============================================================================
|
||||
// Core Domain Types — ten99timecard
|
||||
// ============================================================================
|
||||
|
||||
/** ISO-8601 date string (YYYY-MM-DD) */
|
||||
export type ISODate = string;
|
||||
|
||||
/** Epoch milliseconds */
|
||||
export type EpochMs = number;
|
||||
|
||||
// ─── Work Log ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A work-log entry. Users may enter EITHER a flat dollar amount OR
|
||||
* hours × rate. If both are present, amount wins. A work log does NOT
|
||||
* constitute taxable income — that's what Payment is for.
|
||||
*/
|
||||
export interface WorkEntry {
|
||||
id: string;
|
||||
date: ISODate;
|
||||
description: string;
|
||||
/** Flat dollar amount (takes precedence if set alongside hours/rate) */
|
||||
amount?: number;
|
||||
/** Decimal hours (e.g. 1.5 = 90 min) */
|
||||
hours?: number;
|
||||
/** Rate per hour in USD */
|
||||
rate?: number;
|
||||
/** Optional client/project tag */
|
||||
client?: string;
|
||||
createdAt: EpochMs;
|
||||
updatedAt: EpochMs;
|
||||
}
|
||||
|
||||
/** Computed dollar value of a work entry */
|
||||
export function workEntryValue(e: WorkEntry): number {
|
||||
if (e.amount != null) return e.amount;
|
||||
if (e.hours != null && e.rate != null) return e.hours * e.rate;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ─── Payments (taxable income) ───────────────────────────────────────────────
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
date: ISODate;
|
||||
amount: number;
|
||||
/** Who paid you */
|
||||
payer: string;
|
||||
/** Optional: which work entries this payment covers */
|
||||
workEntryIds?: string[];
|
||||
/** 1099-NEC, 1099-K, 1099-MISC, direct, etc. */
|
||||
form?: '1099-NEC' | '1099-K' | '1099-MISC' | 'direct' | 'other';
|
||||
notes?: string;
|
||||
createdAt: EpochMs;
|
||||
updatedAt: EpochMs;
|
||||
}
|
||||
|
||||
// ─── Expenses ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Expense {
|
||||
id: string;
|
||||
date: ISODate;
|
||||
amount: number;
|
||||
description: string;
|
||||
/** Whether this expense is tax-deductible (Schedule C) */
|
||||
deductible: boolean;
|
||||
category?: string;
|
||||
/** Set when auto-generated from a RecurringExpense template */
|
||||
recurringSourceId?: string;
|
||||
createdAt: EpochMs;
|
||||
updatedAt: EpochMs;
|
||||
}
|
||||
|
||||
export type RecurringFrequency = 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'annually';
|
||||
|
||||
export interface RecurringExpense {
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
category?: string;
|
||||
deductible: boolean;
|
||||
frequency: RecurringFrequency;
|
||||
/** Day of month (1–28) for monthly/quarterly/annual schedules */
|
||||
dayOfMonth: number;
|
||||
startDate: ISODate;
|
||||
endDate?: ISODate | null;
|
||||
createdAt: EpochMs;
|
||||
updatedAt: EpochMs;
|
||||
}
|
||||
|
||||
// ─── Work Timer ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single split recorded from the live timer.
|
||||
* Durations are stored in milliseconds for precision.
|
||||
*/
|
||||
export interface TimerSplit {
|
||||
id: string;
|
||||
/** When this split started (epoch ms) */
|
||||
startedAt: EpochMs;
|
||||
/** Total elapsed milliseconds for this split (excluding pauses) */
|
||||
elapsedMs: number;
|
||||
/** Rate per hour at the time this split was recorded */
|
||||
rate: number;
|
||||
/** Optional label */
|
||||
label?: string;
|
||||
/** Has this split been pushed to the work log? */
|
||||
recorded: boolean;
|
||||
/** If recorded, the WorkEntry id it created */
|
||||
recordedWorkEntryId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live timer state — persisted to cookies for crash recovery.
|
||||
* The running clock's elapsed time = (now - runStartedAt) + accumulatedMs
|
||||
* when running; = accumulatedMs when paused.
|
||||
*/
|
||||
export interface TimerState {
|
||||
/** Current rate per hour for the LIVE clock (splits keep their own rate) */
|
||||
currentRate: number;
|
||||
/** Is the clock currently running? */
|
||||
running: boolean;
|
||||
/** Epoch ms when the current run segment started (only meaningful if running) */
|
||||
runStartedAt: EpochMs | null;
|
||||
/** Milliseconds accumulated from prior run segments (i.e. before last pause) */
|
||||
accumulatedMs: number;
|
||||
/** Recorded splits */
|
||||
splits: TimerSplit[];
|
||||
/** Heartbeat — last time we wrote state to cookies. Used for crash detection. */
|
||||
lastHeartbeat: EpochMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crash-recovery banner payload. If the page loads and finds a TimerState
|
||||
* with running=true and a stale heartbeat, we show this.
|
||||
*/
|
||||
export interface CrashRecovery {
|
||||
/** When the page was last seen alive */
|
||||
crashTime: EpochMs;
|
||||
/** When the page was reloaded */
|
||||
reloadTime: EpochMs;
|
||||
/** reloadTime - crashTime */
|
||||
gapMs: number;
|
||||
}
|
||||
|
||||
// ─── Tax ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type FilingStatus = 'single' | 'mfj' | 'mfs' | 'hoh';
|
||||
|
||||
/**
|
||||
* User-provided tax-year inputs. These are the fields the Tax page will
|
||||
* PROMPT FOR (highlighted) when they're missing but needed for a calculation.
|
||||
*/
|
||||
export interface TaxPaymentRecord {
|
||||
id: string;
|
||||
date: ISODate;
|
||||
amount: number;
|
||||
/** Which quarter this payment applies to (optional) */
|
||||
quarter?: 1 | 2 | 3 | 4;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface TaxInputs {
|
||||
taxYear: number;
|
||||
filingStatus: FilingStatus;
|
||||
/** Previous year's AGI — needed for safe-harbor calculation */
|
||||
priorYearAGI?: number;
|
||||
/** Previous year's total tax — needed for 100%/110% safe harbor */
|
||||
priorYearTax?: number;
|
||||
/** W-2 wages also earned this year (affects brackets) */
|
||||
w2Wages?: number;
|
||||
/** Federal withholding already taken (from W-2 or other) */
|
||||
federalWithholding?: number;
|
||||
/** Manual lump-sum override for estimated payments (legacy) */
|
||||
estimatedPaymentsMade?: number;
|
||||
/** Individually logged estimated tax payments */
|
||||
taxPayments?: TaxPaymentRecord[];
|
||||
/** State for state-tax estimate (optional; federal always computed) */
|
||||
state?: string;
|
||||
}
|
||||
|
||||
/** A single missing-field prompt produced by the tax engine */
|
||||
export interface TaxPrompt {
|
||||
field: keyof TaxInputs;
|
||||
label: string;
|
||||
reason: string;
|
||||
/** Show as a highlighted input in the UI */
|
||||
severity: 'required' | 'recommended';
|
||||
}
|
||||
|
||||
/** Output of the tax calculation engine */
|
||||
export interface TaxResult {
|
||||
taxYear: number;
|
||||
/** Adjusted gross income */
|
||||
agi: number;
|
||||
|
||||
// Schedule C equivalents
|
||||
grossReceipts: number; // sum of payments
|
||||
deductibleExpenses: number; // sum of deductible expenses
|
||||
netProfit: number; // grossReceipts - deductibleExpenses
|
||||
|
||||
// Self-employment tax
|
||||
seTaxableBase: number; // netProfit * 0.9235
|
||||
socialSecurityTax: number;
|
||||
medicareTax: number;
|
||||
additionalMedicareTax: number;
|
||||
totalSETax: number;
|
||||
|
||||
// Income tax
|
||||
seTaxDeduction: number; // 50% of SE tax (above-the-line)
|
||||
qbiDeduction: number; // Section 199A, up to 20% of QBI
|
||||
standardDeduction: number;
|
||||
taxableIncome: number;
|
||||
federalIncomeTax: number;
|
||||
|
||||
// Bottom line
|
||||
totalFederalTax: number; // income tax + SE tax
|
||||
alreadyPaid: number; // withholding + estimated payments made
|
||||
remainingDue: number;
|
||||
|
||||
// Quarterly estimates
|
||||
quarterlySchedule: QuarterlyPayment[];
|
||||
safeHarborAmount: number | null; // null if we lack prior-year data
|
||||
safeHarborMet: boolean | null;
|
||||
|
||||
// Missing-data prompts for the UI
|
||||
prompts: TaxPrompt[];
|
||||
|
||||
// Warnings/notes for display
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface QuarterlyPayment {
|
||||
quarter: 1 | 2 | 3 | 4;
|
||||
dueDate: ISODate;
|
||||
/** Amount recommended for this quarter (even split of annual estimate) */
|
||||
projectedAmount: number;
|
||||
/** Adjusted amount if remaining estimate distributed across unpaid quarters */
|
||||
remainingAmount: number;
|
||||
/** Safe-harbor minimum (if prior-year data available) */
|
||||
safeHarborAmount: number | null;
|
||||
/** Has this due date passed? */
|
||||
isPastDue: boolean;
|
||||
}
|
||||
|
||||
// ─── Charts & Dashboard ──────────────────────────────────────────────────────
|
||||
|
||||
export type ChartType = 'line' | 'bar' | 'area' | 'pie';
|
||||
export type ChartMetric =
|
||||
| 'workValue'
|
||||
| 'payments'
|
||||
| 'expenses'
|
||||
| 'netIncome'
|
||||
| 'cumulativePayments'
|
||||
| 'cumulativeNet';
|
||||
export type ChartGranularity = 'day' | 'week' | 'month' | 'year';
|
||||
|
||||
export interface ChartConfig {
|
||||
id: string;
|
||||
type: ChartType;
|
||||
metrics: ChartMetric[];
|
||||
granularity: ChartGranularity;
|
||||
/** ISO date range; null = auto (current year) */
|
||||
rangeStart: ISODate | null;
|
||||
rangeEnd: ISODate | null;
|
||||
/** Y-axis override; null = auto */
|
||||
yMin: number | null;
|
||||
yMax: number | null;
|
||||
title?: string;
|
||||
/** Rolling average window in periods; null/undefined = disabled */
|
||||
rollingAvgWindow?: number | null;
|
||||
}
|
||||
|
||||
export interface DashboardConfig {
|
||||
charts: ChartConfig[];
|
||||
/** Which stat widgets to show at top of dashboard */
|
||||
widgets: DashboardWidget[];
|
||||
/** Per-ledger-page chart configs */
|
||||
workCharts: ChartConfig[];
|
||||
paymentsCharts: ChartConfig[];
|
||||
expensesCharts: ChartConfig[];
|
||||
/** Tax page charts — empty by default */
|
||||
taxCharts: ChartConfig[];
|
||||
/** Which stat tiles to show on each ledger page */
|
||||
workTiles: LedgerTile[];
|
||||
paymentsTiles: LedgerTile[];
|
||||
expensesTiles: LedgerTile[];
|
||||
/** Which tax result fields to show as tiles on the tax page */
|
||||
taxTiles: TaxTile[];
|
||||
}
|
||||
|
||||
export type DashboardWidget =
|
||||
| 'ytdWorkValue'
|
||||
| 'ytdWorkProj'
|
||||
| 'ytdPayments'
|
||||
| 'ytdPaymentsProj'
|
||||
| 'ytdExpenses'
|
||||
| 'ytdNet'
|
||||
| 'ytdNetProj'
|
||||
| 'nextQuarterlyDue'
|
||||
| 'projectedAnnualTax'
|
||||
| 'ytdActualTax'
|
||||
| 'taxRemainingDue'
|
||||
| 'avgMonthlyNet'
|
||||
| 'avgDailyWork';
|
||||
|
||||
export type LedgerTile =
|
||||
| 'ytd'
|
||||
| 'avgMonth'
|
||||
| 'yearProj'
|
||||
| 'thisMonth'
|
||||
| 'avgDay'
|
||||
| 'monthProj'
|
||||
| 'today';
|
||||
|
||||
export type TaxTile =
|
||||
| 'grossReceipts'
|
||||
| 'deductibleExpenses'
|
||||
| 'netProfit'
|
||||
| 'seTaxableBase'
|
||||
| 'socialSecurityTax'
|
||||
| 'medicareTax'
|
||||
| 'additionalMedicareTax'
|
||||
| 'totalSETax'
|
||||
| 'seTaxDeduction'
|
||||
| 'qbiDeduction'
|
||||
| 'standardDeduction'
|
||||
| 'agi'
|
||||
| 'taxableIncome'
|
||||
| 'federalIncomeTax'
|
||||
| 'totalFederalTax'
|
||||
| 'alreadyPaid'
|
||||
| 'remainingDue';
|
||||
|
||||
// ─── Themes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ThemeName =
|
||||
| 'standard'
|
||||
| 'sakura'
|
||||
| 'pumpkin'
|
||||
| 'fall'
|
||||
| 'aqua'
|
||||
| 'lavender'
|
||||
| 'comic'
|
||||
| 'manga'
|
||||
| 'highcontrast'
|
||||
| 'cyberpunk';
|
||||
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
|
||||
// ─── Settings & App State ────────────────────────────────────────────────────
|
||||
|
||||
export interface Settings {
|
||||
theme: ThemeName;
|
||||
mode: ThemeMode;
|
||||
/** Default hourly rate, pre-fills timer & new work entries */
|
||||
defaultRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The entire persisted app state. This whole object is encrypted
|
||||
* before hitting any storage backend (cookies, file, or Mongo blob).
|
||||
*/
|
||||
export interface AppData {
|
||||
workEntries: WorkEntry[];
|
||||
payments: Payment[];
|
||||
expenses: Expense[];
|
||||
recurringExpenses: RecurringExpense[];
|
||||
taxInputs: Record<number, TaxInputs>; // keyed by tax year
|
||||
dashboard: DashboardConfig;
|
||||
settings: Settings;
|
||||
/** Monotonically increasing for optimistic cloud sync */
|
||||
version: number;
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
// ─── Stats ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PeriodStats {
|
||||
/** Period label, e.g. "2025", "2025-03", "2025-03-15" */
|
||||
label: string;
|
||||
workValue: number;
|
||||
payments: number;
|
||||
expenses: number;
|
||||
deductibleExpenses: number;
|
||||
net: number;
|
||||
/** Average per child period (e.g. year→avg per month) */
|
||||
avgPerChild: number | null;
|
||||
/** Projection to end of current period based on run rate */
|
||||
projected: number | null;
|
||||
/** Number of child periods (for context on averages) */
|
||||
childCount: number;
|
||||
}
|
||||
|
||||
/** Hierarchical tree node for the expandable spreadsheet */
|
||||
export interface HierNode {
|
||||
key: string;
|
||||
level: 'year' | 'month' | 'day' | 'item';
|
||||
label: string;
|
||||
value: number;
|
||||
children: HierNode[];
|
||||
/** Only populated on 'item' leaves */
|
||||
entry?: WorkEntry | Payment | Expense;
|
||||
}
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
24
vite.config.ts
Normal file
24
vite.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/__tests__/setup.ts',
|
||||
css: true,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
exclude: ['**/*.d.ts', '**/__tests__/**', '**/main.tsx'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue