# 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?