commit 27bb45f7df2770149ad876e26f17456a180d2d0f Author: Deven Thiel Date: Wed Mar 4 21:21:59 2026 -0500 initial code commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..927ae68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.env +.env.local +coverage/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..043133b --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# ten99timecard + +Income logging, projections, and quarterly estimated tax calculations for US +1099 contractors. Entirely client-side by default — your data is encrypted with +your password and stored in a browser cookie. Optionally sync to a self-hosted +MongoDB backend (still encrypted end-to-end; the server never sees plaintext). + +**Not tax advice.** Calculations are federal-only approximations. Talk to a CPA. + +--- + +## What's inside + +| Page | What it does | +| --- | --- | +| **Dashboard** | Configurable stat widgets + charts. YTD income, projected annual tax, next quarterly due date. | +| **Ledger** | Three tabs: **Work Log** (non-taxable billable record — flat amount *or* hours×rate), **Payments** (taxable 1099 income), **Expenses** (with deductible flag). Each is a year→month→day→item collapsible spreadsheet with global expand-to-level controls. Period summaries show totals, per-child averages, and linear projections. | +| **Tax** | Full estimated-tax breakdown: SE tax (with SS wage-base cap + additional Medicare), QBI deduction, progressive federal brackets, safe harbor (100%/110% rule), quarterly schedule with past-due flags. Fields you haven't filled in (prior-year AGI, W-2 withholding, etc.) appear as **highlighted prompts** explaining why they'd sharpen the estimate. | +| **Timer** | Billable-work stopwatch. Start / Pause / Split / Reset. Each split captures the rate *at split time* (changing the rate never rewrites history). Editable split table with two-step confirm. One-click push to Work Log. Logged rows get a badge. **Crash-proof:** state is heartbeated to a cookie every second; if the page reloads while running, a red banner shows exactly when you crashed, when you came back, and the gap — with a button to subtract the gap or keep it (because you were working during the outage). | +| **Settings** | Theme picker (10 themes × dark/light), default hourly rate, storage mode (cookie / file / cloud), encrypted file import/export. | + +### Themes +Standard · Sakura · Pumpkin · Fall · Aqua · Lavender · Comic · Manga · High Contrast · Cyberpunk — each in dark & light. + +--- + +## Quick start + +```bash +npm install +npm run dev # client on http://localhost:5173 +``` + +The app works out of the box with **cookie storage**. No server, no database, +no account. Open the page, create a local vault (username + password), start +logging. Your password derives an AES-256-GCM key via PBKDF2 (100k rounds); +the cookie holds only ciphertext. + +### Run the cloud sync server (optional) + +```bash +cp server/.env.example server/.env # edit MONGO_URI + JWT_SECRET +npm run dev:server # Express on http://localhost:4000 +``` + +Then switch storage mode to **Cloud** in Settings. The server stores one opaque +blob per user. Plaintext never leaves the browser. + +### Google OAuth (optional) + +Fill `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` in `server/.env`. Without these +the server still serves email+password auth. + +--- + +## Tests + +```bash +npm test # 155 tests: 135 client (Vitest+RTL) + 20 server (supertest+mongodb-memory-server) +npm run build # tsc --noEmit && vite build, both workspaces +``` + +Coverage spans the tax engine, crypto round-trip, cookie chunking, statistics +rollup/projection, timer crash recovery, store CRUD, and component interaction. + +--- + +## Stack + +**Client:** Vite · React 18 · TypeScript 5 · Zustand · React Router · Recharts · date-fns · Web Crypto API +**Server:** Express · MongoDB · bcrypt · jsonwebtoken · Passport (Google OAuth) · zod +**Testing:** Vitest · React Testing Library · jsdom · supertest · mongodb-memory-server + +Everything is MIT/Apache/BSD licensed. + +--- + +## Tax math (summary) + +- **SE tax** = 15.3% of `netProfit × 0.9235` + (12.4% Social Security up to wage base, 2.9% Medicare uncapped, +0.9% additional Medicare above threshold) +- **Half of SE tax** is an above-the-line deduction +- **QBI §199A** = 20% of qualified business income, limited to 20% of taxable income, phased out above threshold +- **Taxable income** = gross − deductible expenses − ½SE − QBI − standard deduction − other adjustments +- **Safe harbor** = pay the lesser of 90% of current-year tax or 100% (110% if prior AGI > $150k) of prior-year tax, split across four quarters +- Tax year data shipped for **2024** and **2025**; unknown years fall back to closest + +State tax is intentionally out of scope. + +--- + +## Storage model + +| Mode | Where | Encrypted? | +| --- | --- | --- | +| Cookie (default) | `document.cookie`, chunked at ~3.8KB, mirrored to localStorage | ✅ AES-256-GCM | +| File | Download/upload `.t99` blob | ✅ Same key derivation | +| Cloud | `PUT /api/data` on your server | ✅ Encrypted *before* upload | + +Ciphertext format: `base64( salt[16] || iv[12] || aes-gcm-ciphertext )`. +Lose your password → lose your data. That's the point. + +--- + +## License + +MIT diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..c285952 --- /dev/null +++ b/client/index.html @@ -0,0 +1,15 @@ + + + + + + ten99timecard — 1099 Income & Tax Tracker + + + + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..5c74e6b --- /dev/null +++ b/client/package.json @@ -0,0 +1,36 @@ +{ + "name": "ten99timecard-client", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "zustand": "^4.5.5", + "recharts": "^2.12.7", + "date-fns": "^3.6.0", + "clsx": "^2.1.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.10", + "vitest": "^2.1.4", + "@vitest/coverage-v8": "^2.1.4", + "@testing-library/react": "^16.0.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.5.2", + "jsdom": "^25.0.1" + } +} diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..dd43b8c --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,58 @@ +import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom'; +import { useAppStore } from '@/store/appStore'; +import { ThemeProvider } from '@/themes/ThemeProvider'; +import { LoginScreen } from '@/components/auth/LoginScreen'; +import { DashboardPage } from '@/pages/DashboardPage'; +import { LedgerPage } from '@/pages/LedgerPage'; +import { TaxPage } from '@/pages/TaxPage'; +import { TimerPage } from '@/pages/TimerPage'; +import { SettingsPage } from '@/pages/SettingsPage'; + +export function App() { + const unlocked = useAppStore((s) => s.localAuth.unlocked); + const username = useAppStore((s) => s.localAuth.username); + const saving = useAppStore((s) => s.saving); + const saveError = useAppStore((s) => s.lastSaveError); + + return ( + + {!unlocked ? ( + + ) : ( + +
+
+ ten99timecard + +
+ {saving && Saving…} + {saveError && ⚠ Save failed} + {username} +
+
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+ )} +
+ ); +} diff --git a/client/src/__tests__/appStore.test.ts b/client/src/__tests__/appStore.test.ts new file mode 100644 index 0000000..7fedcff --- /dev/null +++ b/client/src/__tests__/appStore.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useAppStore } from '@/store/appStore'; + +describe('appStore — CRUD', () => { + beforeEach(async () => { + // Reset to logged-in fresh state + useAppStore.setState({ + data: { + workEntries: [], + payments: [], + expenses: [], + taxInputs: {}, + dashboard: { charts: [], widgets: [] }, + settings: { theme: 'standard', mode: 'dark', storageMode: 'cookie', defaultRate: 50 }, + version: 1, + }, + localAuth: { unlocked: true, username: 'test' }, + cloudAuth: { token: null, email: null, provider: null }, + vault: null, // no persistence in tests + }); + }); + + it('addWorkEntry assigns id and timestamps', () => { + const e = useAppStore.getState().addWorkEntry({ + date: '2024-01-01', description: 'code review', amount: 150, + }); + expect(e.id).toBeTruthy(); + expect(e.createdAt).toBeGreaterThan(0); + expect(useAppStore.getState().data.workEntries).toHaveLength(1); + }); + + it('updateWorkEntry patches fields', () => { + const e = useAppStore.getState().addWorkEntry({ + date: '2024-01-01', description: 'old', amount: 100, + }); + useAppStore.getState().updateWorkEntry(e.id, { description: 'new' }); + const updated = useAppStore.getState().data.workEntries[0]; + expect(updated.description).toBe('new'); + expect(updated.amount).toBe(100); // unchanged + expect(updated.updatedAt).toBeGreaterThanOrEqual(e.createdAt); + }); + + it('deleteWorkEntry removes it', () => { + const e = useAppStore.getState().addWorkEntry({ + date: '2024-01-01', description: 'x', amount: 1, + }); + useAppStore.getState().deleteWorkEntry(e.id); + expect(useAppStore.getState().data.workEntries).toHaveLength(0); + }); + + it('addPayment / addExpense follow same pattern', () => { + const p = useAppStore.getState().addPayment({ + date: '2024-01-01', amount: 5000, payer: 'Acme', + }); + const ex = useAppStore.getState().addExpense({ + date: '2024-01-01', amount: 200, description: 'laptop', deductible: true, + }); + expect(p.id).toBeTruthy(); + expect(ex.id).toBeTruthy(); + expect(useAppStore.getState().data.payments).toHaveLength(1); + expect(useAppStore.getState().data.expenses).toHaveLength(1); + }); + + it('mutations bump version counter', () => { + const v0 = useAppStore.getState().data.version; + useAppStore.getState().addWorkEntry({ date: '2024-01-01', description: 'x' }); + const v1 = useAppStore.getState().data.version; + expect(v1).toBe(v0 + 1); + }); + + it('setTaxInputs merges per-year', () => { + useAppStore.getState().setTaxInputs(2024, { priorYearAGI: 50000 }); + useAppStore.getState().setTaxInputs(2024, { priorYearTax: 6000 }); + const ti = useAppStore.getState().data.taxInputs[2024]; + expect(ti.priorYearAGI).toBe(50000); + expect(ti.priorYearTax).toBe(6000); + expect(ti.filingStatus).toBe('single'); + }); + + it('chart add/update/remove', () => { + useAppStore.getState().addChart({ title: 'test chart' }); + const charts = useAppStore.getState().data.dashboard.charts; + expect(charts).toHaveLength(1); + const id = charts[0].id; + + useAppStore.getState().updateChart(id, { type: 'bar' }); + expect(useAppStore.getState().data.dashboard.charts[0].type).toBe('bar'); + + useAppStore.getState().removeChart(id); + expect(useAppStore.getState().data.dashboard.charts).toHaveLength(0); + }); + + it('setTheme updates both theme and mode', () => { + useAppStore.getState().setTheme('cyberpunk', 'light'); + expect(useAppStore.getState().data.settings.theme).toBe('cyberpunk'); + expect(useAppStore.getState().data.settings.mode).toBe('light'); + }); + + it('setDefaultRate updates settings', () => { + useAppStore.getState().setDefaultRate(85); + expect(useAppStore.getState().data.settings.defaultRate).toBe(85); + }); +}); + +describe('appStore — auth', () => { + beforeEach(() => { + useAppStore.getState().logout(); + }); + + it('register creates encrypted vault and unlocks', async () => { + await useAppStore.getState().register('alice', 'my-strong-password'); + expect(useAppStore.getState().localAuth.unlocked).toBe(true); + expect(useAppStore.getState().localAuth.username).toBe('alice'); + expect(document.cookie).toContain('t99_alice'); + }); + + it('register fails if user exists', async () => { + await useAppStore.getState().register('bob', 'password123'); + useAppStore.getState().logout(); + await expect( + useAppStore.getState().register('bob', 'different') + ).rejects.toThrow(); + }); + + it('login succeeds with correct password', async () => { + await useAppStore.getState().register('carol', 'secret-pass-123'); + useAppStore.getState().addWorkEntry({ date: '2024-01-01', description: 'marker', amount: 999 }); + await useAppStore.getState().persist(); + useAppStore.getState().logout(); + + await useAppStore.getState().login('carol', 'secret-pass-123'); + expect(useAppStore.getState().localAuth.unlocked).toBe(true); + expect(useAppStore.getState().data.workEntries[0].description).toBe('marker'); + }); + + it('login fails with wrong password', async () => { + await useAppStore.getState().register('dave', 'correct-pass'); + useAppStore.getState().logout(); + await expect( + useAppStore.getState().login('dave', 'wrong-pass') + ).rejects.toThrow(/Wrong password/); + }); + + it('logout locks and clears data', async () => { + await useAppStore.getState().register('eve', 'password123'); + useAppStore.getState().addWorkEntry({ date: '2024-01-01', description: 'secret' }); + useAppStore.getState().logout(); + expect(useAppStore.getState().localAuth.unlocked).toBe(false); + expect(useAppStore.getState().data.workEntries).toHaveLength(0); + }); +}); diff --git a/client/src/__tests__/components.test.tsx b/client/src/__tests__/components.test.tsx new file mode 100644 index 0000000..a1cca2e --- /dev/null +++ b/client/src/__tests__/components.test.tsx @@ -0,0 +1,287 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { HierSpreadsheet } from '@/components/spreadsheet/HierSpreadsheet'; +import { WorkEntryForm, ExpenseForm } from '@/components/spreadsheet/EntryForm'; +import { Modal, ConfirmDialog } from '@/components/common/Modal'; +import { LoginScreen } from '@/components/auth/LoginScreen'; +import { useAppStore } from '@/store/appStore'; +import type { HierNode } from '@/types'; + +// ─── HierSpreadsheet ───────────────────────────────────────────────────────── + +describe('HierSpreadsheet', () => { + const tree: HierNode[] = [ + { + key: '2024', level: 'year', label: '2024', value: 1500, + children: [ + { + key: '2024-03', level: 'month', label: 'March 2024', value: 1500, + children: [ + { + key: '2024-03-15', level: 'day', label: 'Mar 15', value: 1500, + children: [ + { key: 'i1', level: 'item', label: 'Task A', value: 1000, children: [] }, + { key: 'i2', level: 'item', label: 'Task B', value: 500, children: [] }, + ], + }, + ], + }, + ], + }, + ]; + + it('shows only top-level rows by default', () => { + render(); + expect(screen.getByText('2024')).toBeInTheDocument(); + expect(screen.queryByText('March 2024')).not.toBeInTheDocument(); + }); + + it('expands row on click', async () => { + render(); + await userEvent.click(screen.getByText('2024')); + expect(screen.getByText('March 2024')).toBeInTheDocument(); + }); + + it('collapses expanded row on second click', async () => { + render(); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByText('Grand Total')).toBeInTheDocument(); + // $1,500.00 appears in both year row and grand total + expect(screen.getAllByText('$1,500.00').length).toBeGreaterThanOrEqual(1); + }); + + it('calls onEdit for item rows', async () => { + const onEdit = vi.fn(); + render(); + 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(); + expect(screen.getByText(/No entries yet/)).toBeInTheDocument(); + }); +}); + +// ─── WorkEntryForm ─────────────────────────────────────────────────────────── + +describe('WorkEntryForm', () => { + // Helper: inputs are wrapped in .field divs with sibling