commit e3070461c79f97196e42beb60f660786a574ed0e Author: Your Name Date: Tue Feb 17 17:37:20 2026 +0200 Initial commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..641e5b9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "mcp__searxng__searxng_web_search", + "Bash(git init:*)", + "Bash(git add:*)", + "Bash(git commit:*)" + ] + } +} diff --git a/docs/plans/2026-02-17-local-time-tracker-design.md b/docs/plans/2026-02-17-local-time-tracker-design.md new file mode 100644 index 0000000..84dbf55 --- /dev/null +++ b/docs/plans/2026-02-17-local-time-tracker-design.md @@ -0,0 +1,217 @@ +# Local Time Tracker - Design Document + +**Date:** 2026-02-17 +**Status:** Approved + +--- + +## 1. Overview + +A portable desktop time tracking application for freelancers and small teams. Replaces cloud-based services like Toggl Track, Harvest, and Clockify with a fully local-first solution that stores all data next to the executable. + +**Target Users:** Freelancers, small teams (2-10), independent contractors +**Platform:** Windows (Tauri v2 + Vue 3) + +--- + +## 2. Architecture + +### Tech Stack +- **Framework:** Tauri v2 (Rust backend) +- **Frontend:** Vue 3 + TypeScript + Vite +- **UI Library:** shadcn-vue v2.4.3 + Tailwind CSS v4 +- **State Management:** Pinia +- **Database:** SQLite (rusqlite) +- **Charts:** Chart.js +- **PDF Generation:** jsPDF +- **Icons:** Lucide Vue + +### Data Storage (Portable) +All data stored in `./data/` folder next to the executable: +- `./data/timetracker.db` - SQLite database +- `./data/exports/` - CSV and PDF exports +- `./data/logs/` - Application logs +- `./data/config.json` - User preferences + +**No registry, no AppData, no cloud dependencies.** + +--- + +## 3. UI/UX Design + +### Window Model +- **Main Window:** Frameless with custom title bar (1200x800 default, resizable, min 800x600) +- **Title Bar:** Integrated menu + window controls (minimize, maximize, close) +- **Timer Bar:** Always visible below title bar + +### Layout Structure +``` +┌─────────────────────────────────────────────────────────┐ +│ [Logo] LocalTimeTracker [File Edit View Help] [─][□][×] │ ← Custom Title Bar +├─────────────────────────────────────────────────────────┤ +│ [▶ START] 00:00:00 [Project ▼] [Task ▼] │ ← Timer Bar +├────────────┬────────────────────────────────────────────┤ +│ │ │ +│ Dashboard │ Main Content Area │ +│ Timer │ │ +│ Projects │ - Dashboard: Overview charts │ +│ Entries │ - Timer: Active timer view │ +│ Reports │ - Projects: Project/client list │ +│ Invoices │ - Entries: Time entry table │ +│ Settings │ - Reports: Charts and summaries │ +│ │ - Invoices: Invoice builder │ +│ │ - Settings: Preferences │ +│ │ │ +└────────────┴────────────────────────────────────────────┘ +``` + +### Visual Design + +**Color Palette (Dark Mode + Amber):** +| Role | Color | Usage | +|------|-------|-------| +| Background | `#0F0F0F` | Page background | +| Surface | `#1A1A1A` | Cards, panels | +| Surface Elevated | `#242424` | Hover states, modals | +| Border | `#2E2E2E` | Subtle separation | +| Text Primary | `#FFFFFF` (87%) | Headings, body | +| Text Secondary | `#A0A0A0` (60%) | Labels, hints | +| Accent (Amber) | `#F59E0B` | Primary actions, active states | +| Accent Hover | `#D97706` | Button hover | +| Accent Light | `#FCD34D` | Highlights | +| Success | `#22C55E` | Positive status | +| Warning | `#F59E0B` | Warnings | +| Error | `#EF4444` | Errors | + +**Typography:** +- **Headings/Body:** IBM Plex Sans +- **Timer/Data:** IBM Plex Mono +- **Scale:** 1.250 (Major Third) + +**Spacing:** +- Base unit: 4px +- Comfortable density (16px standard padding) + +**Border Radius:** 8px (cards, buttons, inputs) + +### Components + +**Navigation:** +- Sidebar (220px fixed) +- Items: Dashboard, Timer, Projects, Entries, Reports, Invoices, Settings +- Active state: Amber highlight + left border accent + +**Timer Bar:** +- Start/Stop button (amber when active) +- Running time display (mono font, large) +- Project selector dropdown +- Task selector dropdown + +**Buttons:** +- Primary: Amber fill +- Secondary: Outlined +- Ghost: Text only + +**Cards:** +- Dark surface (`#1A1A1A`) +- Subtle border (`#2E2E2E`) +- Rounded corners (8px) + +**Forms:** +- Dark background +- Amber focus ring + +--- + +## 4. Functional Requirements + +### 4.1 Timer +- One-click start/stop timer +- Project and task assignment +- Optional notes/description +- Manual time entry for forgotten sessions +- Idle detection with prompt to keep/discard idle time +- Reminder notifications + +### 4.2 Projects & Clients +- Create/edit/delete projects +- Group projects by client +- Set hourly rate per project +- Archive projects + +### 4.3 Time Entries +- List all time entries with filtering +- Edit existing entries +- Delete entries +- Bulk actions (delete, export) + +### 4.4 Reports +- Weekly/monthly summaries +- Bar charts for time distribution +- Pie charts for project breakdown +- Filter by date range, project, client +- Export to CSV + +### 4.5 Invoices +- Generate from tracked time +- Customizable line items +- Client details +- Tax rates, discounts +- Payment terms +- PDF export + +### 4.6 Settings +- Theme preferences (dark mode only initially) +- Default hourly rate +- Idle detection settings +- Reminder intervals +- Data export/import +- Clear all data + +### 4.7 System Integration +- System tray residence +- Compact floating timer window (optional) +- Global hotkey to start/stop +- Auto-start on login (optional) +- Native notifications + +--- + +## 5. Data Model + +### Tables +- `clients` - Client information +- `projects` - Projects linked to clients +- `tasks` - Tasks within projects +- `time_entries` - Individual time entries +- `invoices` - Generated invoices +- `invoice_items` - Line items for invoices +- `settings` - User preferences + +--- + +## 6. Motion & Interactions + +**Animation Style:** Moderate/Purposeful (200-300ms transitions) + +**Key Interactions:** +- Timer: Subtle amber glow when running +- Cards: Soft lift on hover +- Buttons: Scale/color change on press +- View transitions: Fade + slight slide +- Empty states: Animated illustrations + +--- + +## 7. Acceptance Criteria + +1. ✅ App launches without errors +2. ✅ Timer starts/stops and tracks time correctly +3. ✅ Projects and clients can be created/edited/deleted +4. ✅ Time entries are persisted to SQLite +5. ✅ Reports display accurate charts +6. ✅ Invoices generate valid PDFs +7. ✅ All data stored in ./data/ folder (portable) +8. ✅ Custom title bar with working window controls +9. ✅ System tray integration works +10. ✅ Dark mode with amber accent throughout diff --git a/docs/plans/2026-02-17-local-time-tracker-implementation.md b/docs/plans/2026-02-17-local-time-tracker-implementation.md new file mode 100644 index 0000000..d508b69 --- /dev/null +++ b/docs/plans/2026-02-17-local-time-tracker-implementation.md @@ -0,0 +1,2270 @@ +# Local Time Tracker - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a portable desktop time tracking app with invoicing using Tauri v2 + Vue 3 + +**Architecture:** Tauri v2 with Vue 3 frontend. Rust backend handles SQLite database and system tray. All data stored in portable `./data/` folder next to executable. Custom frameless window with title bar. + +**Tech Stack:** Tauri v2, Vue 3 + TypeScript + Vite, shadcn-vue v2.4.3, Tailwind CSS v4, Pinia, Chart.js, jsPDF, Lucide Vue, rusqlite + +--- + +## Phase 1: Project Setup + +### Task 1: Initialize Tauri v2 + Vue 3 Project + +**Files:** +- Create: `package.json` +- Create: `vite.config.ts` +- Create: `tsconfig.json` +- Create: `index.html` +- Create: `src/main.ts` +- Create: `src/App.vue` + +**Step 1: Create package.json with dependencies** + +```json +{ + "name": "local-time-tracker", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "vue": "^3.5.0", + "vue-router": "^4.5.0", + "pinia": "^2.3.0", + "@vueuse/core": "^12.0.0", + "chart.js": "^4.4.0", + "vue-chartjs": "^5.3.0", + "jspdf": "^2.5.0", + "lucide-vue-next": "^0.400.0", + "shadcn-vue": "^2.4.3", + "@tauri-apps/api": "^2.2.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.2.0", + "@vitejs/plugin-vue": "^5.2.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vue-tsc": "^2.2.0", + "tailwindcss": "^4.0.0", + "@tailwindcss/vite": "^4.0.0", + "autoprefixer": "^10.4.0" + } +} +``` + +**Step 2: Create vite.config.ts** + +```typescript +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import tailwindcss from "@tailwindcss/vite"; +import path from "path"; + +export default defineConfig({ + plugins: [vue(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + clearScreen: false, + server: { + port: 1420, + strictPort: true, + watch: { + ignored: ["**/src-tauri/**"], + }, + }, +}); +``` + +**Step 3: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} +``` + +**Step 4: Create tsconfig.node.json** + +```json +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} +``` + +**Step 5: Create index.html** + +```html + + + + + + + LocalTimeTracker + + +
+ + + +``` + +**Step 6: Create src/main.ts** + +```typescript +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import App from "./App.vue"; +import router from "./router"; +import "./styles/main.css"; + +const app = createApp(App); + +app.use(createPinia()); +app.use(router); + +app.mount("#app"); +``` + +**Step 7: Create src/styles/main.css** + +```css +@import "tailwindcss"; + +@theme { + --color-background: #0F0F0F; + --color-surface: #1A1A1A; + --color-surface-elevated: #242424; + --color-border: #2E2E2E; + --color-text-primary: #FFFFFF; + --color-text-secondary: #A0A0A0; + --color-amber: #F59E0B; + --color-amber-hover: #D97706; + --color-amber-light: #FCD34D; + --color-success: #22C55E; + --color-warning: #F59E0B; + --color-error: #EF4444; + + --font-sans: 'IBM Plex Sans', system-ui, sans-serif; + --font-mono: 'IBM Plex Mono', monospace; +} + +body { + margin: 0; + background-color: var(--color-background); + color: var(--color-text-primary); + font-family: var(--font-sans); +} +``` + +**Step 8: Create src/App.vue** + +```vue + + + +``` + +**Step 9: Commit** + +```bash +git add . +git commit -m "feat: initialize Tauri v2 + Vue 3 project" +``` + +--- + +### Task 2: Initialize Tauri Backend + +**Files:** +- Create: `src-tauri/Cargo.toml` +- Create: `src-tauri/tauri.conf.json` +- Create: `src-tauri/src/main.rs` +- Create: `src-tauri/src/lib.rs` +- Create: `src-tauri/build.rs` +- Create: `src-tauri/capabilities/default.json` + +**Step 1: Create src-tauri/Cargo.toml** + +```toml +[package] +name = "local-time-tracker" +version = "1.0.0" +description = "A local time tracking app with invoicing" +authors = ["you"] +edition = "2021" + +[lib] +name = "local_time_tracker_lib" +crate-type = ["lib", "cdylib", "staticlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["tray-icon"] } +tauri-plugin-shell = "2" + = "2" +tauri-plugin-fs = "2" +tauri-plugintauri-plugin-dialog-notification = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rusqlite = { version = "0.32", features = ["bundled"] } +chrono = { version = "0.4", features = ["serde"] } +directories = "5" +log = "0.4" +env_logger = "0.11" + +[profile.release] +panic = "abort" +codegen-units = 1 +lto = true +opt-level = "s" +strip = true +``` + +**Step 2: Create src-tauri/tauri.conf.json** + +```json +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "LocalTimeTracker", + "version": "1.0.0", + "identifier": "com.localtimetracker.app", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist", + "devtools": true + }, + "app": { + "windows": [ + { + "title": "LocalTimeTracker", + "width": 1200, + "height": 800, + "minWidth": 800, + "minHeight": 600, + "decorations": false, + "transparent": false, + "resizable": true, + "center": true + } + ], + "trayIcon": { + "iconPath": "icons/icon.png", + "iconAsTemplate": true + }, + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "webviewInstallMode": { + "type": "embedBootstrapper" + } + } + }, + "plugins": { + "shell": { + "open": true + } + } +} +``` + +**Step 3: Create src-tauri/build.rs** + +```rust +fn main() { + tauri_build::build() +} +``` + +**Step 4: Create src-tauri/src/main.rs** + +```rust +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +fn main() { + local_time_tracker_lib::run(); +} +``` + +**Step 5: Create src-tauri/src/lib.rs** + +```rust +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::sync::Mutex; +use tauri::{Manager, State}; +use std::path::PathBuf; + +mod database; +mod commands; + +pub struct AppState { + pub db: Mutex, +} + +fn get_data_dir() -> PathBuf { + let exe_path = std::env::current_exe().unwrap(); + let exe_dir = exe_path.parent().unwrap(); + let data_dir = exe_dir.join("data"); + std::fs::create_dir_all(&data_dir).ok(); + data_dir +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + env_logger::init(); + + let data_dir = get_data_dir(); + let db_path = data_dir.join("timetracker.db"); + + let conn = Connection::open(&db_path).expect("Failed to open database"); + database::init_db(&conn).expect("Failed to initialize database"); + + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_notification::init()) + .manage(AppState { db: Mutex::new(conn) }) + .invoke_handler(tauri::generate_handler![ + commands::get_clients, + commands::create_client, + commands::update_client, + commands::delete_client, + commands::get_projects, + commands::create_project, + commands::update_project, + commands::delete_project, + commands::get_tasks, + commands::create_task, + commands::delete_task, + commands::get_time_entries, + commands::create_time_entry, + commands::update_time_entry, + commands::delete_time_entry, + commands::get_reports, + commands::create_invoice, + commands::get_invoices, + commands::get_settings, + commands::update_settings, + ]) + .setup(|app| { + #[cfg(desktop)] + { + use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + use tauri::menu::{Menu, MenuItem}; + + let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let show = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &quit])?; + + let _tray = TrayIconBuilder::new() + .menu(&menu) + .menu_on_left_click(false) + .on_menu_event(|app, event| { + match event.id.as_ref() { + "quit" => { + app.exit(0); + } + "show" => { + if let Some(window) = app.get_webview_window("main") { + window.show().ok(); + window.set_focus().ok(); + } + } + _ => {} + } + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + window.show().ok(); + window.set_focus().ok(); + } + } + }) + .build(app)?; + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +**Step 6: Create src-tauri/src/database.rs** + +```rust +use rusqlite::Connection; + +pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { + conn.execute( + "CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT, + address TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER, + name TEXT NOT NULL, + hourly_rate REAL DEFAULT 0, + color TEXT DEFAULT '#F59E0B', + archived INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES clients(id) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS time_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + task_id INTEGER, + description TEXT, + start_time TEXT NOT NULL, + end_time TEXT, + duration INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (task_id) REFERENCES tasks(id) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL, + invoice_number TEXT NOT NULL, + date TEXT NOT NULL, + due_date TEXT, + subtotal REAL DEFAULT 0, + tax_rate REAL DEFAULT 0, + tax_amount REAL DEFAULT 0, + discount REAL DEFAULT 0, + total REAL DEFAULT 0, + notes TEXT, + status TEXT DEFAULT 'draft', + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES clients(id) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS invoice_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invoice_id INTEGER NOT NULL, + description TEXT NOT NULL, + quantity REAL DEFAULT 1, + rate REAL DEFAULT 0, + amount REAL DEFAULT 0, + time_entry_id INTEGER, + FOREIGN KEY (invoice_id) REFERENCES invoices(id), + FOREIGN KEY (time_entry_id) REFERENCES time_entries(id) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + )", + [], + )?; + + // Insert default settings + conn.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES ('default_hourly_rate', '50')", + [], + )?; + conn.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES ('idle_detection', 'true')", + [], + )?; + conn.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES ('idle_timeout', '5')", + [], + )?; + conn.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES ('reminder_interval', '30')", + [], + )?; + + Ok(()) +} +``` + +**Step 7: Create src-tauri/src/commands.rs** + +```rust +use crate::AppState; +use rusqlite::params; +use serde::{Deserialize, Serialize}; +use tauri::State; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Client { + pub id: Option, + pub name: String, + pub email: Option, + pub address: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Project { + pub id: Option, + pub client_id: Option, + pub name: String, + pub hourly_rate: f64, + pub color: String, + pub archived: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Task { + pub id: Option, + pub project_id: i64, + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TimeEntry { + pub id: Option, + pub project_id: i64, + pub task_id: Option, + pub description: Option, + pub start_time: String, + pub end_time: Option, + pub duration: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Invoice { + pub id: Option, + pub client_id: i64, + pub invoice_number: String, + pub date: String, + pub due_date: Option, + pub subtotal: f64, + pub tax_rate: f64, + pub tax_amount: f64, + pub discount: f64, + pub total: f64, + pub notes: Option, + pub status: String, +} + +// Client commands +#[tauri::command] +pub fn get_clients(state: State) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("SELECT id, name, email, address FROM clients ORDER BY name").map_err(|e| e.to_string())?; + let clients = stmt.query_map([], |row| { + Ok(Client { + id: Some(row.get(0)?), + name: row.get(1)?, + email: row.get(2)?, + address: row.get(3)?, + }) + }).map_err(|e| e.to_string())?; + clients.collect::, _>>().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn create_client(state: State, client: Client) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute( + "INSERT INTO clients (name, email, address) VALUES (?1, ?2, ?3)", + params![client.name, client.email, client.address], + ).map_err(|e| e.to_string())?; + Ok(conn.last_insert_rowid()) +} + +#[tauri::command] +pub fn update_client(state: State, client: Client) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute( + "UPDATE clients SET name = ?1, email = ?2, address = ?3 WHERE id = ?4", + params![client.name, client.email, client.address, client.id], + ).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn delete_client(state: State, id: i64) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM clients WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; + Ok(()) +} + +// Project commands +#[tauri::command] +pub fn get_projects(state: State) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("SELECT id, client_id, name, hourly_rate, color, archived FROM projects ORDER BY name").map_err(|e| e.to_string())?; + let projects = stmt.query_map([], |row| { + Ok(Project { + id: Some(row.get(0)?), + client_id: row.get(1)?, + name: row.get(2)?, + hourly_rate: row.get(3)?, + color: row.get(4)?, + archived: row.get::<_, i32>(5)? != 0, + }) + }).map_err(|e| e.to_string())?; + projects.collect::, _>>().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn create_project(state: State, project: Project) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute( + "INSERT INTO projects (client_id, name, hourly_rate, color, archived) VALUES (?1, ?2, ?3, ?4, ?5)", + params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32], + ).map_err(|e| e.to_string())?; + Ok(conn.last_insert_rowid()) +} + +#[tauri::command] +pub fn update_project(state: State, project: Project) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute( + "UPDATE projects SET client_id = ?1, name = ?2, hourly_rate = ?3, color = ?4, archived = ?5 WHERE id = ?6", + params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32, project.id], + ).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn delete_project(state: State, id: i64) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM projects WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; + Ok(()) +} + +// Task commands +#[tauri::command] +pub fn get_tasks(state: State, project_id: i64) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("SELECT id, project_id, name FROM tasks WHERE project_id = ?1 ORDER BY name").map_err(|e| e.to_string())?; + let tasks = stmt.query_map(params![project_id], |row| { + Ok(Task { + id: Some(row.get(0)?), + project_id: row.get(1)?, + name: row.get(2)?, + }) + }).map_err(|e| e.to_string())?; + tasks.collect::, _>>().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn create_task(state: State, task: Task) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute( + "INSERT INTO tasks (project_id, name) VALUES (?1, ?2)", + params![task.project_id, task.name], + ).map_err(|e| e.to_string())?; + Ok(conn.last_insert_rowid()) +} + +#[tauri::command] +pub fn delete_task(state: State, id: i64) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM tasks WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; + Ok(()) +} + +// Time entry commands +#[tauri::command] +pub fn get_time_entries(state: State, start_date: Option, end_date: Option) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let query = match (start_date, end_date) { + (Some(start), Some(end)) => { + let mut stmt = conn.prepare( + "SELECT id, project_id, task_id, description, start_time, end_time, duration + FROM time_entries + WHERE date(start_time) >= date(?1) AND date(start_time) <= date(?2) + ORDER BY start_time DESC" + ).map_err(|e| e.to_string())?; + stmt.query_map(params![start, end], |row| { + Ok(TimeEntry { + id: Some(row.get(0)?), + project_id: row.get(1)?, + task_id: row.get(2)?, + description: row.get(3)?, + start_time: row.get(4)?, + end_time: row.get(5)?, + duration: row.get(6)?, + }) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())? + } + _ => { + let mut stmt = conn.prepare( + "SELECT id, project_id, task_id, description, start_time, end_time, duration + FROM time_entries ORDER BY start_time DESC LIMIT 100" + ).map_err(|e| e.to_string())?; + stmt.query_map([], |row| { + Ok(TimeEntry { + id: Some(row.get(0)?), + project_id: row.get(1)?, + task_id: row.get(2)?, + description: row.get(3)?, + start_time: row.get(4)?, + end_time: row.get(5)?, + duration: row.get(6)?, + }) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())? + } + }; + Ok(query) +} + +#[tauri::command] +pub fn create_time_entry(state: State, entry: TimeEntry) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute( + "INSERT INTO time_entries (project_id, task_id, description, start_time, end_time, duration) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![entry.project_id, entry.task_id, entry.description, entry.start_time, entry.end_time, entry.duration], + ).map_err(|e| e.to_string())?; + Ok(conn.last_insert_rowid()) +} + +#[tauri::command] +pub fn update_time_entry(state: State, entry: TimeEntry) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute( + "UPDATE time_entries SET project_id = ?1, task_id = ?2, description = ?3, + start_time = ?4, end_time = ?5, duration = ?6 WHERE id = ?7", + params![entry.project_id, entry.task_id, entry.description, entry.start_time, entry.end_time, entry.duration, entry.id], + ).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn delete_time_entry(state: State, id: i64) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM time_entries WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; + Ok(()) +} + +// Reports +#[tauri::command] +pub fn get_reports(state: State, start_date: String, end_date: String) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + + // Total hours + let total: i64 = conn.query_row( + "SELECT COALESCE(SUM(duration), 0) FROM time_entries + WHERE date(start_time) >= date(?1) AND date(start_time) <= date(?2)", + params![start_date, end_date], + |row| row.get(0), + ).map_err(|e| e.to_string())?; + + // By project + let mut stmt = conn.prepare( + "SELECT p.name, p.color, SUM(t.duration) as total_duration + FROM time_entries t + JOIN projects p ON t.project_id = p.id + WHERE date(t.start_time) >= date(?1) AND date(t.start_time) <= date(?2) + GROUP BY p.id + ORDER BY total_duration DESC" + ).map_err(|e| e.to_string())?; + + let by_project: Vec = stmt.query_map(params![start_date, end_date], |row| { + Ok(serde_json::json!({ + "name": row.get::<_, String>(0)?, + "color": row.get::<_, String>(1)?, + "duration": row.get::<_, i64>(2)? + })) + }).map_err(|e| e.to_string())? + .collect::, _>>().map_err(|e| e.to_string())?; + + Ok(serde_json::json!({ + "totalSeconds": total, + "byProject": by_project + })) +} + +// Invoice commands +#[tauri::command] +pub fn create_invoice(state: State, invoice: Invoice) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute( + "INSERT INTO invoices (client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![invoice.client_id, invoice.invoice_number, invoice.date, invoice.due_date, + invoice.subtotal, invoice.tax_rate, invoice.tax_amount, invoice.discount, + invoice.total, invoice.notes, invoice.status], + ).map_err(|e| e.to_string())?; + Ok(conn.last_insert_rowid()) +} + +#[tauri::command] +pub fn get_invoices(state: State) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let mut stmt = conn.prepare( + "SELECT id, client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status + FROM invoices ORDER BY date DESC" + ).map_err(|e| e.to_string())?; + let invoices = stmt.query_map([], |row| { + Ok(Invoice { + id: Some(row.get(0)?), + client_id: row.get(1)?, + invoice_number: row.get(2)?, + date: row.get(3)?, + due_date: row.get(4)?, + subtotal: row.get(5)?, + tax_rate: row.get(6)?, + tax_amount: row.get(7)?, + discount: row.get(8)?, + total: row.get(9)?, + notes: row.get(10)?, + status: row.get(11)?, + }) + }).map_err(|e| e.to_string())?; + invoices.collect::, _>>().map_err(|e| e.to_string()) +} + +// Settings commands +#[tauri::command] +pub fn get_settings(state: State) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("SELECT key, value FROM settings").map_err(|e| e.to_string())?; + let settings = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }).map_err(|e| e.to_string())?; + + let mut result = std::collections::HashMap::new(); + for setting in settings { + let (key, value) = setting.map_err(|e| e.to_string())?; + result.insert(key, value); + } + Ok(result) +} + +#[tauri::command] +pub fn update_settings(state: State, key: String, value: String) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![key, value], + ).map_err(|e| e.to_string())?; + Ok(()) +} +``` + +**Step 8: Create src-tauri/capabilities/default.json** + +```json +{ + "$schema": "https://schema.tauri.app/config/2", + "identifier": "default", + "description": "Default capabilities for the app", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-minimize", + "core:window:allow-maximize", + "core:window:allow-unmaximize", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-focus", + "core:window:allow-is-maximized", + "shell:allow-open", + "dialog:allow-open", + "dialog:allow-save", + "dialog:allow-message", + "dialog:allow-ask", + "dialog:allow-confirm", + "fs:allow-read-text-file", + "fs:allow-write-text-file", + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify" + ] +} +``` + +**Step 9: Commit** + +```bash +git add . +git commit -m "feat: initialize Tauri backend with SQLite database" +``` + +--- + +### Task 3: Create Vue Router and Pinia Stores + +**Files:** +- Create: `src/router/index.ts` +- Create: `src/stores/timer.ts` +- Create: `src/stores/projects.ts` +- Create: `src/stores/clients.ts` +- Create: `src/stores/entries.ts` +- Create: `src/stores/invoices.ts` +- Create: `src/stores/settings.ts` + +**Step 1: Create src/router/index.ts** + +```typescript +import { createRouter, createWebHistory } from "vue-router"; +import Dashboard from "@/views/Dashboard.vue"; +import Timer from "@/views/Timer.vue"; +import Projects from "@/views/Projects.vue"; +import Entries from "@/views/Entries.vue"; +import Reports from "@/views/Reports.vue"; +import Invoices from "@/views/Invoices.vue"; +import Settings from "@/views/Settings.vue"; + +const routes = [ + { path: "/", name: "Dashboard", component: Dashboard }, + { path: "/timer", name: "Timer", component: Timer }, + { path: "/projects", name: "Projects", component: Projects }, + { path: "/entries", name: "Entries", component: Entries }, + { path: "/reports", name: "Reports", component: Reports }, + { path: "/invoices", name: "Invoices", component: Invoices }, + { path: "/settings", name: "Settings", component: Settings }, +]; + +const router = createRouter({ + history: createWebHistory(), + routes, +}); + +export default router; +``` + +**Step 2: Create src/stores/timer.ts** + +```typescript +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import { invoke } from "@tauri-apps/api/core"; + +export interface TimeEntry { + id?: number; + project_id: number; + task_id?: number; + description?: string; + start_time: string; + end_time?: string; + duration: number; +} + +export const useTimerStore = defineStore("timer", () => { + const isRunning = ref(false); + const startTime = ref(null); + const currentEntry = ref(null); + const selectedProjectId = ref(null); + const selectedTaskId = ref(null); + const description = ref(""); + const elapsedSeconds = ref(0); + let intervalId: number | null = null; + + const formattedTime = computed(() => { + const hours = Math.floor(elapsedSeconds.value / 3600); + const minutes = Math.floor((elapsedSeconds.value % 3600) / 60); + const seconds = elapsedSeconds.value % 60; + return `${hours.toString().padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + }); + + function start() { + if (isRunning.value || !selectedProjectId.value) return; + + isRunning.value = true; + startTime.value = new Date(); + currentEntry.value = { + project_id: selectedProjectId.value, + task_id: selectedTaskId.value || undefined, + description: description.value || undefined, + start_time: startTime.value.toISOString(), + duration: 0, + }; + + intervalId = window.setInterval(() => { + if (startTime.value) { + elapsedSeconds.value = Math.floor( + (Date.now() - startTime.value.getTime()) / 1000 + ); + } + }, 1000); + } + + async function stop() { + if (!isRunning.value || !currentEntry.value) return; + + isRunning.value = false; + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + + const endTime = new Date(); + currentEntry.value.end_time = endTime.toISOString(); + currentEntry.value.duration = elapsedSeconds.value; + + try { + await invoke("create_time_entry", { entry: currentEntry.value }); + } catch (e) { + console.error("Failed to save time entry:", e); + } + + elapsedSeconds.value = 0; + startTime.value = null; + currentEntry.value = null; + description.value = ""; + } + + function setProject(projectId: number) { + selectedProjectId.value = projectId; + selectedTaskId.value = null; + } + + function setTask(taskId: number) { + selectedTaskId.value = taskId; + } + + function setDescription(desc: string) { + description.value = desc; + } + + return { + isRunning, + startTime, + currentEntry, + selectedProjectId, + selectedTaskId, + description, + elapsedSeconds, + formattedTime, + start, + stop, + setProject, + setTask, + setDescription, + }; +}); +``` + +**Step 3: Create src/stores/clients.ts** + +```typescript +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { invoke } from "@tauri-apps/api/core"; + +export interface Client { + id?: number; + name: string; + email?: string; + address?: string; +} + +export const useClientsStore = defineStore("clients", () => { + const clients = ref([]); + const loading = ref(false); + + async function fetchClients() { + loading.value = true; + try { + clients.value = await invoke("get_clients"); + } catch (e) { + console.error("Failed to fetch clients:", e); + } finally { + loading.value = false; + } + } + + async function createClient(client: Client) { + try { + const id = await invoke("create_client", { client }); + client.id = id; + clients.value.push(client); + return id; + } catch (e) { + console.error("Failed to create client:", e); + throw e; + } + } + + async function updateClient(client: Client) { + try { + await invoke("update_client", { client }); + const index = clients.value.findIndex((c) => c.id === client.id); + if (index !== -1) { + clients.value[index] = client; + } + } catch (e) { + console.error("Failed to update client:", e); + throw e; + } + } + + async function deleteClient(id: number) { + try { + await invoke("delete_client", { id }); + clients.value = clients.value.filter((c) => c.id !== id); + } catch (e) { + console.error("Failed to delete client:", e); + throw e; + } + } + + return { + clients, + loading, + fetchClients, + createClient, + updateClient, + deleteClient, + }; +}); +``` + +**Step 4: Create src/stores/projects.ts** + +```typescript +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { invoke } from "@tauri-apps/api/core"; + +export interface Project { + id?: number; + client_id?: number; + name: string; + hourly_rate: number; + color: string; + archived: boolean; +} + +export const useProjectsStore = defineStore("projects", () => { + const projects = ref([]); + const loading = ref(false); + + async function fetchProjects() { + loading.value = true; + try { + projects.value = await invoke("get_projects"); + } catch (e) { + console.error("Failed to fetch projects:", e); + } finally { + loading.value = false; + } + } + + async function createProject(project: Project) { + try { + const id = await invoke("create_project", { project }); + project.id = id; + projects.value.push(project); + return id; + } catch (e) { + console.error("Failed to create project:", e); + throw e; + } + } + + async function updateProject(project: Project) { + try { + await invoke("update_project", { project }); + const index = projects.value.findIndex((p) => p.id === project.id); + if (index !== -1) { + projects.value[index] = project; + } + } catch (e) { + console.error("Failed to update project:", e); + throw e; + } + } + + async function deleteProject(id: number) { + try { + await invoke("delete_project", { id }); + projects.value = projects.value.filter((p) => p.id !== id); + } catch (e) { + console.error("Failed to delete project:", e); + throw e; + } + } + + return { + projects, + loading, + fetchProjects, + createProject, + updateProject, + deleteProject, + }; +}); +``` + +**Step 5: Create src/stores/entries.ts** + +```typescript +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { invoke } from "@tauri-apps/api/core"; + +export interface TimeEntry { + id?: number; + project_id: number; + task_id?: number; + description?: string; + start_time: string; + end_time?: string; + duration: number; +} + +export const useEntriesStore = defineStore("entries", () => { + const entries = ref([]); + const loading = ref(false); + + async function fetchEntries(startDate?: string, endDate?: string) { + loading.value = true; + try { + entries.value = await invoke("get_time_entries", { + startDate, + endDate, + }); + } catch (e) { + console.error("Failed to fetch entries:", e); + } finally { + loading.value = false; + } + } + + async function createEntry(entry: TimeEntry) { + try { + const id = await invoke("create_time_entry", { entry }); + entry.id = id; + entries.value.unshift(entry); + return id; + } catch (e) { + console.error("Failed to create entry:", e); + throw e; + } + } + + async function updateEntry(entry: TimeEntry) { + try { + await invoke("update_time_entry", { entry }); + const index = entries.value.findIndex((e) => e.id === entry.id); + if (index !== -1) { + entries.value[index] = entry; + } + } catch (e) { + console.error("Failed to update entry:", e); + throw e; + } + } + + async function deleteEntry(id: number) { + try { + await invoke("delete_time_entry", { id }); + entries.value = entries.value.filter((e) => e.id !== id); + } catch (e) { + console.error("Failed to delete entry:", e); + throw e; + } + } + + return { + entries, + loading, + fetchEntries, + createEntry, + updateEntry, + deleteEntry, + }; +}); +``` + +**Step 6: Create src/stores/invoices.ts** + +```typescript +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { invoke } from "@tauri-apps/api/core"; + +export interface Invoice { + id?: number; + client_id: number; + invoice_number: string; + date: string; + due_date?: string; + subtotal: number; + tax_rate: number; + tax_amount: number; + discount: number; + total: number; + notes?: string; + status: string; +} + +export const useInvoicesStore = defineStore("invoices", () => { + const invoices = ref([]); + const loading = ref(false); + + async function fetchInvoices() { + loading.value = true; + try { + invoices.value = await invoke("get_invoices"); + } catch (e) { + console.error("Failed to fetch invoices:", e); + } finally { + loading.value = false; + } + } + + async function createInvoice(invoice: Invoice) { + try { + const id = await invoke("create_invoice", { invoice }); + invoice.id = id; + invoices.value.unshift(invoice); + return id; + } catch (e) { + console.error("Failed to create invoice:", e); + throw e; + } + } + + return { + invoices, + loading, + fetchInvoices, + createInvoice, + }; +}); +``` + +**Step 7: Create src/stores/settings.ts** + +```typescript +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { invoke } from "@tauri-apps/api/core"; + +export const useSettingsStore = defineStore("settings", () => { + const settings = ref>({}); + const loading = ref(false); + + async function fetchSettings() { + loading.value = true; + try { + settings.value = await invoke>("get_settings"); + } catch (e) { + console.error("Failed to fetch settings:", e); + } finally { + loading.value = false; + } + } + + async function updateSetting(key: string, value: string) { + try { + await invoke("update_settings", { key, value }); + settings.value[key] = value; + } catch (e) { + console.error("Failed to update setting:", e); + throw e; + } + } + + return { + settings, + loading, + fetchSettings, + updateSetting, + }; +}); +``` + +**Step 8: Commit** + +```bash +git add . +git commit -m "feat: add Vue router and Pinia stores" +``` + +--- + +### Task 4: Create UI Components + +**Files:** +- Create: `src/components/TitleBar.vue` +- Create: `src/components/TimerBar.vue` +- Create: `src/components/Sidebar.vue` +- Create: `src/components/ui/button.vue` +- Create: `src/components/ui/input.vue` +- Create: `src/components/ui/select.vue` +- Create: `src/components/ui/card.vue` +- Create: `src/components/ui/dialog.vue` +- Create: `src/components/ui/table.vue` + +**Step 1: Create src/components/TitleBar.vue** + +```vue + + + +``` + +**Step 2: Create src/components/TimerBar.vue** + +```vue + + + +``` + +**Step 3: Create src/components/Sidebar.vue** + +```vue + + + +``` + +**Step 4: Commit** + +```bash +git add . +git commit -m "feat: add core UI components (TitleBar, TimerBar, Sidebar)" +``` + +--- + +## Phase 2: Views Implementation + +### Task 5: Create Dashboard View + +**Files:** +- Create: `src/views/Dashboard.vue` + +```vue + + + +``` + +**Step 2: Commit** + +```bash +git add . +git commit -m "feat: add Dashboard view" +``` + +--- + +### Task 6: Create Projects View + +**Files:** +- Create: `src/views/Projects.vue` + +```vue + + + +``` + +**Step 2: Commit** + +```bash +git add . +git commit -m "feat: add Projects view with CRUD" +``` + +--- + +### Task 7: Create Remaining Views + +**Files:** +- Create: `src/views/Timer.vue` +- Create: `src/views/Entries.vue` +- Create: `src/views/Reports.vue` +- Create: `src/views/Invoices.vue` +- Create: `src/views/Settings.vue` + +(Implementation follows similar patterns to Dashboard and Projects views - will be in the actual build) + +**Step 1: Commit** + +```bash +git add . +git commit -m "feat: add remaining views" +``` + +--- + +### Task 8: Add PDF Invoice Generation + +**Files:** +- Create: `src/utils/invoicePdf.ts` + +```typescript +import jsPDF from "jspdf"; +import type { Invoice } from "@/stores/invoices"; +import type { Client } from "@/stores/clients"; + +export function generateInvoicePdf(invoice: Invoice, client: Client, items: any[]) { + const doc = new jsPDF(); + + // Header + doc.setFontSize(24); + doc.setTextColor(245, 158, 11); // Amber + doc.text("INVOICE", 20, 30); + + // Invoice Details + doc.setFontSize(10); + doc.setTextColor(100); + doc.text(`Invoice #: ${invoice.invoice_number}`, 20, 45); + doc.text(`Date: ${invoice.date}`, 20, 52); + if (invoice.due_date) { + doc.text(`Due Date: ${invoice.due_date}`, 20, 59); + } + + // Client Info + doc.setFontSize(12); + doc.setTextColor(0); + doc.text("Bill To:", 20, 75); + doc.setFontSize(10); + doc.text(client.name, 20, 82); + if (client.email) doc.text(client.email, 20, 89); + if (client.address) { + const addressLines = client.address.split("\n"); + let y = 96; + addressLines.forEach(line => { + doc.text(line, 20, y); + y += 7; + }); + } + + // Items Table + let y = 120; + doc.setFillColor(245, 158, 11); + doc.rect(20, y - 5, 170, 8, "F"); + doc.setTextColor(255); + doc.setFontSize(9); + doc.text("Description", 22, y); + doc.text("Qty", 110, y); + doc.text("Rate", 130, y); + doc.text("Amount", 160, y); + + y += 10; + doc.setTextColor(0); + items.forEach(item => { + doc.text(item.description.substring(0, 40), 22, y); + doc.text(item.quantity.toString(), 110, y); + doc.text(`$${item.rate.toFixed(2)}`, 130, y); + doc.text(`$${item.amount.toFixed(2)}`, 160, y); + y += 8; + }); + + // Totals + y += 10; + doc.line(20, y, 190, y); + y += 10; + doc.text(`Subtotal: $${invoice.subtotal.toFixed(2)}`, 130, y); + if (invoice.discount > 0) { + y += 8; + doc.text(`Discount: -$${invoice.discount.toFixed(2)}`, 130, y); + } + if (invoice.tax_rate > 0) { + y += 8; + doc.text(`Tax (${invoice.tax_rate}%): $${invoice.tax_amount.toFixed(2)}`, 130, y); + } + y += 10; + doc.setFontSize(12); + doc.setTextColor(245, 158, 11); + doc.text(`Total: $${invoice.total.toFixed(2)}`, 130, y); + + // Notes + if (invoice.notes) { + y += 20; + doc.setFontSize(10); + doc.setTextColor(100); + doc.text("Notes:", 20, y); + doc.setFontSize(9); + doc.text(invoice.notes, 20, y + 7); + } + + return doc; +} +``` + +**Step 2: Commit** + +```bash +git add . +git commit -m "feat: add PDF invoice generation" +``` + +--- + +## Phase 3: System Integration + +### Task 9: System Tray and Window Controls + +(Already implemented in lib.rs and TitleBar.vue) + +### Task 10: Build and Test + +**Step 1: Install dependencies** + +```bash +npm install +``` + +**Step 2: Run dev mode** + +```bash +npm run tauri dev +``` + +**Step 3: Build for production** + +```bash +npm run tauri build +``` + +**Step 4: Commit** + +```bash +git add . +git commit -m "feat: complete LocalTimeTracker app" +``` + +--- + +## Plan Complete + +The implementation plan is saved to `docs/plans/2026-02-17-local-time-tracker-implementation.md`. + +Two execution options: + +1. **Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration + +2. **Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints + +Which approach?