Files
zeroclock/docs/plans/2026-02-17-local-time-tracker-implementation.md
2026-02-17 17:37:20 +02:00

62 KiB

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

{
  "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

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

{
  "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

{
  "compilerOptions": {
    "composite": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true
  },
  "include": ["vite.config.ts"]
}

Step 5: Create index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>LocalTimeTracker</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Step 6: Create src/main.ts

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

@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

<template>
  <div class="h-screen flex flex-col bg-background">
    <!-- Custom Title Bar -->
    <TitleBar />
    <!-- Timer Bar -->
    <TimerBar />
    <!-- Main Content -->
    <div class="flex flex-1 overflow-hidden">
      <Sidebar />
      <main class="flex-1 overflow-auto p-6">
        <router-view />
      </main>
    </div>
  </div>
</template>

<script setup lang="ts">
import TitleBar from "@/components/TitleBar.vue";
import TimerBar from "@/components/TimerBar.vue";
import Sidebar from "@/components/Sidebar.vue";
</script>

Step 9: Commit

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

[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

{
  "$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

fn main() {
    tauri_build::build()
}

Step 4: Create src-tauri/src/main.rs

#![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

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<Connection>,
}

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

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

use crate::AppState;
use rusqlite::params;
use serde::{Deserialize, Serialize};
use tauri::State;

#[derive(Debug, Serialize, Deserialize)]
pub struct Client {
    pub id: Option<i64>,
    pub name: String,
    pub email: Option<String>,
    pub address: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Project {
    pub id: Option<i64>,
    pub client_id: Option<i64>,
    pub name: String,
    pub hourly_rate: f64,
    pub color: String,
    pub archived: bool,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Task {
    pub id: Option<i64>,
    pub project_id: i64,
    pub name: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct TimeEntry {
    pub id: Option<i64>,
    pub project_id: i64,
    pub task_id: Option<i64>,
    pub description: Option<String>,
    pub start_time: String,
    pub end_time: Option<String>,
    pub duration: i64,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Invoice {
    pub id: Option<i64>,
    pub client_id: i64,
    pub invoice_number: String,
    pub date: String,
    pub due_date: Option<String>,
    pub subtotal: f64,
    pub tax_rate: f64,
    pub tax_amount: f64,
    pub discount: f64,
    pub total: f64,
    pub notes: Option<String>,
    pub status: String,
}

// Client commands
#[tauri::command]
pub fn get_clients(state: State<AppState>) -> Result<Vec<Client>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}

#[tauri::command]
pub fn create_client(state: State<AppState>, client: Client) -> Result<i64, String> {
    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<AppState>, 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<AppState>, 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<AppState>) -> Result<Vec<Project>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}

#[tauri::command]
pub fn create_project(state: State<AppState>, project: Project) -> Result<i64, String> {
    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<AppState>, 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<AppState>, 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<AppState>, project_id: i64) -> Result<Vec<Task>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}

#[tauri::command]
pub fn create_task(state: State<AppState>, task: Task) -> Result<i64, String> {
    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<AppState>, 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<AppState>, start_date: Option<String>, end_date: Option<String>) -> Result<Vec<TimeEntry>, 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::<Result<Vec<_>, _>>().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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
        }
    };
    Ok(query)
}

#[tauri::command]
pub fn create_time_entry(state: State<AppState>, entry: TimeEntry) -> Result<i64, String> {
    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<AppState>, 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<AppState>, 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<AppState>, start_date: String, end_date: String) -> Result<serde_json::Value, String> {
    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<serde_json::Value> = 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::<Result<Vec<_>, _>>().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<AppState>, invoice: Invoice) -> Result<i64, String> {
    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<AppState>) -> Result<Vec<Invoice>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}

// Settings commands
#[tauri::command]
pub fn get_settings(state: State<AppState>) -> Result<std::collections::HashMap<String, String>, 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<AppState>, 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

{
  "$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

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

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

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<Date | null>(null);
  const currentEntry = ref<TimeEntry | null>(null);
  const selectedProjectId = ref<number | null>(null);
  const selectedTaskId = ref<number | null>(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

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<Client[]>([]);
  const loading = ref(false);

  async function fetchClients() {
    loading.value = true;
    try {
      clients.value = await invoke<Client[]>("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<number>("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

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<Project[]>([]);
  const loading = ref(false);

  async function fetchProjects() {
    loading.value = true;
    try {
      projects.value = await invoke<Project[]>("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<number>("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

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<TimeEntry[]>([]);
  const loading = ref(false);

  async function fetchEntries(startDate?: string, endDate?: string) {
    loading.value = true;
    try {
      entries.value = await invoke<TimeEntry[]>("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<number>("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

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<Invoice[]>([]);
  const loading = ref(false);

  async function fetchInvoices() {
    loading.value = true;
    try {
      invoices.value = await invoke<Invoice[]>("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<number>("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

import { defineStore } from "pinia";
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";

export const useSettingsStore = defineStore("settings", () => {
  const settings = ref<Record<string, string>>({});
  const loading = ref(false);

  async function fetchSettings() {
    loading.value = true;
    try {
      settings.value = await invoke<Record<string, string>>("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

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

<template>
  <div class="h-10 bg-surface border-b border-border flex items-center justify-between px-4" data-tauri-drag-region>
    <div class="flex items-center gap-3">
      <Clock class="w-5 h-5 text-amber" />
      <span class="text-sm font-semibold text-text-primary">LocalTimeTracker</span>
    </div>

    <div class="flex items-center gap-1">
      <button
        @click="minimize"
        class="w-10 h-8 flex items-center justify-center hover:bg-surface-elevated rounded transition-colors"
      >
        <Minus class="w-4 h-4 text-text-secondary" />
      </button>
      <button
        @click="toggleMaximize"
        class="w-10 h-8 flex items-center justify-center hover:bg-surface-elevated rounded transition-colors"
      >
        <Square v-if="!isMaximized" class="w-3.5 h-3.5 text-text-secondary" />
        <Copy v-else class="w-3.5 h-3.5 text-text-secondary" />
      </button>
      <button
        @click="close"
        class="w-10 h-8 flex items-center justify-center hover:bg-error rounded transition-colors"
      >
        <X class="w-4 h-4 text-text-secondary hover:text-white" />
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Clock, Minus, Square, Copy, X } from "lucide-vue-next";

const appWindow = getCurrentWindow();
const isMaximized = ref(false);

onMounted(async () => {
  isMaximized.value = await appWindow.isMaximized();
});

async function minimize() {
  await appWindow.minimize();
}

async function toggleMaximize() {
  if (isMaximized.value) {
    await appWindow.unmaximize();
  } else {
    await appWindow.maximize();
  }
  isMaximized.value = !isMaximized.value;
}

async function close() {
  await appWindow.hide();
}
</script>

Step 2: Create src/components/TimerBar.vue

<template>
  <div class="h-14 bg-surface border-b border-border flex items-center px-4 gap-4">
    <!-- Start/Stop Button -->
    <button
      @click="toggleTimer"
      :class="[
        'px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2',
        timerStore.isRunning
          ? 'bg-error hover:bg-red-600 text-white'
          : 'bg-amber hover:bg-amber-hover text-black'
      ]"
    >
      <Play v-if="!timerStore.isRunning" class="w-4 h-4" />
      <Square v-else class="w-4 h-4" />
      {{ timerStore.isRunning ? 'Stop' : 'Start' }}
    </button>

    <!-- Timer Display -->
    <div class="font-mono text-2xl text-text-primary tabular-nums">
      {{ timerStore.formattedTime }}
    </div>

    <!-- Project Selector -->
    <select
      v-model="timerStore.selectedProjectId"
      class="bg-background border border-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-amber"
    >
      <option :value="null">Select Project</option>
      <option
        v-for="project in projectsStore.projects"
        :key="project.id"
        :value="project.id"
      >
        {{ project.name }}
      </option>
    </select>

    <!-- Task Selector -->
    <select
      v-model="timerStore.selectedTaskId"
      :disabled="!timerStore.selectedProjectId"
      class="bg-background border border-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-amber disabled:opacity-50"
    >
      <option :value="null">Select Task</option>
      <option v-for="task in tasks" :key="task.id" :value="task.id">
        {{ task.name }}
      </option>
    </select>

    <!-- Description -->
    <input
      v-model="description"
      type="text"
      placeholder="What are you working on?"
      class="flex-1 bg-background border border-border rounded-lg px-3 py-2 text-sm text-text-primary placeholder-text-secondary focus:outline-none focus:ring-2 focus:ring-amber"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useTimerStore } from "@/stores/timer";
import { useProjectsStore } from "@/stores/projects";
import { invoke } from "@tauri-apps/api/core";
import { Play, Square } from "lucide-vue-next";

const timerStore = useTimerStore();
const projectsStore = useProjectsStore();

const description = ref("");

watch(description, (val) => {
  timerStore.setDescription(val);
});

const tasks = computed(() => {
  if (!timerStore.selectedProjectId) return [];
  // TODO: Fetch tasks for selected project
  return [];
});

function toggleTimer() {
  if (timerStore.isRunning) {
    timerStore.stop();
  } else {
    timerStore.start();
  }
}
</script>

Step 3: Create src/components/Sidebar.vue

<template>
  <aside class="w-56 bg-surface border-r border-border flex flex-col">
    <nav class="flex-1 py-4">
      <router-link
        v-for="item in navItems"
        :key="item.path"
        :to="item.path"
        class="flex items-center gap-3 px-4 py-2.5 mx-2 rounded-lg text-text-secondary hover:bg-surface-elevated hover:text-text-primary transition-colors"
        :class="{ 'bg-surface-elevated text-amber border-l-2 border-amber': isActive(item.path) }"
      >
        <component :is="item.icon" class="w-5 h-5" />
        <span class="text-sm font-medium">{{ item.name }}</span>
      </router-link>
    </nav>
  </aside>
</template>

<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
import {
  LayoutDashboard,
  Timer,
  FolderKanban,
  Clock,
  BarChart3,
  FileText,
  Settings
} from "lucide-vue-next";

const route = useRoute();

const navItems = [
  { name: "Dashboard", path: "/", icon: LayoutDashboard },
  { name: "Timer", path: "/timer", icon: Timer },
  { name: "Projects", path: "/projects", icon: FolderKanban },
  { name: "Entries", path: "/entries", icon: Clock },
  { name: "Reports", path: "/reports", icon: BarChart3 },
  { name: "Invoices", path: "/invoices", icon: FileText },
  { name: "Settings", path: "/settings", icon: Settings },
];

function isActive(path: string) {
  return route.path === path;
}
</script>

Step 4: Commit

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
<template>
  <div class="space-y-6">
    <h1 class="text-2xl font-bold text-text-primary">Dashboard</h1>

    <!-- Stats Cards -->
    <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
      <div class="bg-surface border border-border rounded-lg p-6">
        <div class="text-text-secondary text-sm">This Week</div>
        <div class="text-3xl font-bold text-text-primary mt-1">{{ formatDuration(weeklyStats.totalSeconds) }}</div>
      </div>
      <div class="bg-surface border border-border rounded-lg p-6">
        <div class="text-text-secondary text-sm">This Month</div>
        <div class="text-3xl font-bold text-text-primary mt-1">{{ formatDuration(monthlyStats.totalSeconds) }}</div>
      </div>
      <div class="bg-surface border border-border rounded-lg p-6">
        <div class="text-text-secondary text-sm">Active Projects</div>
        <div class="text-3xl font-bold text-text-primary mt-1">{{ projectsStore.projects.length }}</div>
      </div>
    </div>

    <!-- Weekly Chart -->
    <div class="bg-surface border border-border rounded-lg p-6">
      <h2 class="text-lg font-semibold text-text-primary mb-4">Weekly Overview</h2>
      <div class="h-64">
        <Bar v-if="chartData" :data="chartData" :options="chartOptions" />
      </div>
    </div>

    <!-- Recent Entries -->
    <div class="bg-surface border border-border rounded-lg p-6">
      <h2 class="text-lg font-semibold text-text-primary mb-4">Recent Time Entries</h2>
      <div class="space-y-2">
        <div
          v-for="entry in entriesStore.entries.slice(0, 5)"
          :key="entry.id"
          class="flex items-center justify-between py-2 border-b border-border last:border-0"
        >
          <div class="flex items-center gap-3">
            <div class="w-2 h-2 rounded-full bg-amber"></div>
            <span class="text-text-primary">{{ getProjectName(entry.project_id) }}</span>
          </div>
          <span class="text-text-secondary font-mono">{{ formatDuration(entry.duration) }}</span>
        </div>
        <div v-if="entriesStore.entries.length === 0" class="text-center text-text-secondary py-8">
          No time entries yet. Start tracking!
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useEntriesStore } from "@/stores/entries";
import { useProjectsStore } from "@/stores/projects";
import { invoke } from "@tauri-apps/api/core";
import { Bar } from "vue-chartjs";
import {
  Chart as ChartJS,
  CategoryScale,
  LinearScale,
  BarElement,
  Title,
  Tooltip,
} from "chart.js";

ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip);

const entriesStore = useEntriesStore();
const projectsStore = useProjectsStore();

const weeklyStats = ref({ totalSeconds: 0, byProject: [] });
const monthlyStats = ref({ totalSeconds: 0, byProject: [] });

const chartData = computed(() => {
  const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
  return {
    labels: days,
    datasets: [
      {
        label: "Hours",
        data: [4, 6, 3, 5, 7, 2, 0], // TODO: Real data
        backgroundColor: "#F59E0B",
        borderRadius: 4,
      },
    ],
  };
});

const chartOptions = {
  responsive: true,
  maintainAspectRatio: false,
  plugins: {
    legend: { display: false },
  },
  scales: {
    x: {
      grid: { color: "#2E2E2E" },
      ticks: { color: "#A0A0A0" },
    },
    y: {
      grid: { color: "#2E2E2E" },
      ticks: { color: "#A0A0A0" },
    },
  },
};

function formatDuration(seconds: number) {
  const hours = Math.floor(seconds / 3600);
  const minutes = Math.floor((seconds % 3600) / 60);
  return `${hours}h ${minutes}m`;
}

function getProjectName(projectId: number) {
  const project = projectsStore.projects.find((p) => p.id === projectId);
  return project?.name || "Unknown Project";
}

onMounted(async () => {
  await Promise.all([
    entriesStore.fetchEntries(),
    projectsStore.fetchProjects(),
  ]);

  // Fetch weekly stats
  const now = new Date();
  const weekStart = new Date(now);
  weekStart.setDate(now.getDate() - now.getDay() + 1);
  const weekEnd = new Date(weekStart);
  weekEnd.setDate(weekStart.getDate() + 6);

  try {
    weeklyStats.value = await invoke("get_reports", {
      startDate: weekStart.toISOString().split("T")[0],
      endDate: weekEnd.toISOString().split("T")[0],
    });
  } catch (e) {
    console.error("Failed to fetch weekly stats:", e);
  }
});
</script>

Step 2: Commit

git add .
git commit -m "feat: add Dashboard view"

Task 6: Create Projects View

Files:

  • Create: src/views/Projects.vue
<template>
  <div class="space-y-6">
    <div class="flex items-center justify-between">
      <h1 class="text-2xl font-bold text-text-primary">Projects</h1>
      <button
        @click="showCreateDialog = true"
        class="px-4 py-2 bg-amber hover:bg-amber-hover text-black rounded-lg font-medium transition-colors flex items-center gap-2"
      >
        <Plus class="w-4 h-4" /> Add Project
      </button>
    </div>

    <!-- Projects Grid -->
    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      <div
        v-for="project in projectsStore.projects"
        :key="project.id"
        class="bg-surface border border-border rounded-lg p-4 hover:border-amber transition-colors cursor-pointer"
        @click="selectProject(project)"
      >
        <div class="flex items-start justify-between">
          <div class="flex items-center gap-3">
            <div
              class="w-3 h-3 rounded-full"
              :style="{ backgroundColor: project.color }"
            ></div>
            <div>
              <h3 class="font-medium text-text-primary">{{ project.name }}</h3>
              <p class="text-sm text-text-secondary">
                ${{ project.hourly_rate }}/hr
              </p>
            </div>
          </div>
          <button
            @click.stop="deleteProject(project.id!)"
            class="text-text-secondary hover:text-error transition-colors"
          >
            <Trash2 class="w-4 h-4" />
          </button>
        </div>
      </div>

      <div v-if="projectsStore.projects.length === 0" class="col-span-full">
        <div class="bg-surface border border-border rounded-lg p-12 text-center">
          <FolderKanban class="w-12 h-12 text-text-secondary mx-auto mb-4" />
          <p class="text-text-secondary">No projects yet. Create your first project!</p>
        </div>
      </div>
    </div>

    <!-- Create/Edit Dialog -->
    <div v-if="showCreateDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
      <div class="bg-surface border border-border rounded-lg p-6 w-full max-w-md">
        <h2 class="text-lg font-semibold text-text-primary mb-4">
          {{ editingProject ? 'Edit Project' : 'New Project' }}
        </h2>

        <form @submit.prevent="saveProject" class="space-y-4">
          <div>
            <label class="block text-sm text-text-secondary mb-1">Name</label>
            <input
              v-model="formData.name"
              type="text"
              class="w-full bg-background border border-border rounded-lg px-3 py-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-amber"
              required
            />
          </div>

          <div>
            <label class="block text-sm text-text-secondary mb-1">Client</label>
            <select
              v-model="formData.client_id"
              class="w-full bg-background border border-border rounded-lg px-3 py-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-amber"
            >
              <option :value="null">No Client</option>
              <option v-for="client in clientsStore.clients" :key="client.id" :value="client.id">
                {{ client.name }}
              </option>
            </select>
          </div>

          <div>
            <label class="block text-sm text-text-secondary mb-1">Hourly Rate ($)</label>
            <input
              v-model.number="formData.hourly_rate"
              type="number"
              step="0.01"
              class="w-full bg-background border border-border rounded-lg px-3 py-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-amber"
            />
          </div>

          <div>
            <label class="block text-sm text-text-secondary mb-1">Color</label>
            <input
              v-model="formData.color"
              type="color"
              class="w-full h-10 bg-background border border-border rounded-lg cursor-pointer"
            />
          </div>

          <div class="flex justify-end gap-2 pt-4">
            <button
              type="button"
              @click="closeDialog"
              class="px-4 py-2 text-text-secondary hover:text-text-primary transition-colors"
            >
              Cancel
            </button>
            <button
              type="submit"
              class="px-4 py-2 bg-amber hover:bg-amber-hover text-black rounded-lg font-medium transition-colors"
            >
              {{ editingProject ? 'Save' : 'Create' }}
            </button>
          </div>
        </form>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { useProjectsStore, type Project } from "@/stores/projects";
import { useClientsStore } from "@/stores/clients";
import { Plus, FolderKanban, Trash2 } from "lucide-vue-next";

const projectsStore = useProjectsStore();
const clientsStore = useClientsStore();

const showCreateDialog = ref(false);
const editingProject = ref<Project | null>(null);

const formData = reactive({
  name: "",
  client_id: null as number | null,
  hourly_rate: 50,
  color: "#F59E0B",
  archived: false,
});

function selectProject(project: Project) {
  editingProject.value = project;
  formData.name = project.name;
  formData.client_id = project.client_id || null;
  formData.hourly_rate = project.hourly_rate;
  formData.color = project.color;
  formData.archived = project.archived;
  showCreateDialog.value = true;
}

async function saveProject() {
  const projectData: Project = {
    id: editingProject.value?.id,
    name: formData.name,
    client_id: formData.client_id || undefined,
    hourly_rate: formData.hourly_rate,
    color: formData.color,
    archived: formData.archived,
  };

  if (editingProject.value) {
    await projectsStore.updateProject(projectData);
  } else {
    await projectsStore.createProject(projectData);
  }

  closeDialog();
}

function closeDialog() {
  showCreateDialog.value = false;
  editingProject.value = null;
  formData.name = "";
  formData.client_id = null;
  formData.hourly_rate = 50;
  formData.color = "#F59E0B";
  formData.archived = false;
}

async function deleteProject(id: number) {
  if (confirm("Are you sure you want to delete this project?")) {
    await projectsStore.deleteProject(id);
  }
}

onMounted(async () => {
  await Promise.all([
    projectsStore.fetchProjects(),
    clientsStore.fetchClients(),
  ]);
});
</script>

Step 2: Commit

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

git add .
git commit -m "feat: add remaining views"

Task 8: Add PDF Invoice Generation

Files:

  • Create: src/utils/invoicePdf.ts
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

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

npm install

Step 2: Run dev mode

npm run tauri dev

Step 3: Build for production

npm run tauri build

Step 4: Commit

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?