# 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
Dashboard
This Week
{{ formatDuration(weeklyStats.totalSeconds) }}
This Month
{{ formatDuration(monthlyStats.totalSeconds) }}
Active Projects
{{ projectsStore.projects.length }}
Recent Time Entries
{{ getProjectName(entry.project_id) }}
{{ formatDuration(entry.duration) }}
No time entries yet. Start tracking!
```
**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
{{ project.name }}
${{ project.hourly_rate }}/hr
No projects yet. Create your first project!
{{ editingProject ? 'Edit Project' : 'New Project' }}
```
**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?