- Custom tooltip directive (WCAG AAA) on every button in the app - Two-column timer layout with sticky hero and recent entries sidebar - Timer font selector with 16 monospace Google Fonts and live preview - UI font selector with 15+ Google Fonts - Close-to-tray and minimize-to-tray settings - New app icons (no-glow variants), platform icon set - Mini timer pop-out window - Favorites strip with drag-reorder and inline actions - Comprehensive README with feature documentation - Remove tracked files that belong in gitignore
440 lines
14 KiB
Rust
440 lines
14 KiB
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
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
// Migrate clients table - add new columns (safe to re-run)
|
|
let migration_columns = [
|
|
"ALTER TABLE clients ADD COLUMN company TEXT",
|
|
"ALTER TABLE clients ADD COLUMN phone TEXT",
|
|
"ALTER TABLE clients ADD COLUMN tax_id TEXT",
|
|
"ALTER TABLE clients ADD COLUMN payment_terms TEXT",
|
|
"ALTER TABLE clients ADD COLUMN notes TEXT",
|
|
"ALTER TABLE clients ADD COLUMN currency TEXT",
|
|
];
|
|
for sql in &migration_columns {
|
|
match conn.execute(sql, []) {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
if !msg.contains("duplicate column") {
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
// Migrate projects table - add budget columns (safe to re-run)
|
|
let project_migrations = [
|
|
"ALTER TABLE projects ADD COLUMN budget_hours REAL DEFAULT NULL",
|
|
"ALTER TABLE projects ADD COLUMN budget_amount REAL DEFAULT NULL",
|
|
"ALTER TABLE projects ADD COLUMN rounding_override INTEGER DEFAULT NULL",
|
|
"ALTER TABLE projects ADD COLUMN timeline_override TEXT DEFAULT NULL",
|
|
"ALTER TABLE projects ADD COLUMN notes TEXT",
|
|
"ALTER TABLE projects ADD COLUMN currency TEXT",
|
|
];
|
|
for sql in &project_migrations {
|
|
match conn.execute(sql, []) {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
if !msg.contains("duplicate column") {
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
// Migrate tasks table (safe to re-run)
|
|
let task_migrations = [
|
|
"ALTER TABLE tasks ADD COLUMN estimated_hours REAL DEFAULT NULL",
|
|
"ALTER TABLE tasks ADD COLUMN hourly_rate REAL DEFAULT NULL",
|
|
];
|
|
for sql in &task_migrations {
|
|
match conn.execute(sql, []) {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
if !msg.contains("duplicate column") {
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
// Migrate time_entries table
|
|
let time_entry_migrations = [
|
|
"ALTER TABLE time_entries ADD COLUMN billable INTEGER DEFAULT 1",
|
|
];
|
|
for sql in &time_entry_migrations {
|
|
match conn.execute(sql, []) {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
if !msg.contains("duplicate column") {
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
// Migrate invoices table - add template_id column (safe to re-run)
|
|
let invoice_migrations = [
|
|
"ALTER TABLE invoices ADD COLUMN template_id TEXT DEFAULT 'clean'",
|
|
];
|
|
for sql in &invoice_migrations {
|
|
match conn.execute(sql, []) {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
if !msg.contains("duplicate column") {
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 tracked_apps (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
project_id INTEGER NOT NULL,
|
|
exe_name TEXT NOT NULL,
|
|
exe_path TEXT,
|
|
display_name TEXT,
|
|
FOREIGN KEY (project_id) REFERENCES projects(id)
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS tags (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
color TEXT DEFAULT '#6B7280'
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS entry_tags (
|
|
entry_id INTEGER NOT NULL,
|
|
tag_id INTEGER NOT NULL,
|
|
PRIMARY KEY (entry_id, tag_id),
|
|
FOREIGN KEY (entry_id) REFERENCES time_entries(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS favorites (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
project_id INTEGER NOT NULL,
|
|
task_id INTEGER,
|
|
description TEXT,
|
|
sort_order INTEGER DEFAULT 0,
|
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE SET NULL
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS recurring_entries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
project_id INTEGER NOT NULL,
|
|
task_id INTEGER,
|
|
description TEXT,
|
|
duration INTEGER DEFAULT 0,
|
|
recurrence_rule TEXT NOT NULL,
|
|
time_of_day TEXT DEFAULT '09:00',
|
|
mode TEXT DEFAULT 'prompt',
|
|
enabled INTEGER DEFAULT 1,
|
|
last_triggered TEXT,
|
|
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 expenses (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
project_id INTEGER NOT NULL,
|
|
client_id INTEGER,
|
|
category TEXT DEFAULT 'other',
|
|
description TEXT,
|
|
amount REAL DEFAULT 0,
|
|
date TEXT NOT NULL,
|
|
receipt_path TEXT,
|
|
invoiced INTEGER DEFAULT 0,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (project_id) REFERENCES projects(id),
|
|
FOREIGN KEY (client_id) REFERENCES clients(id)
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS timeline_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
project_id INTEGER NOT NULL,
|
|
exe_name TEXT,
|
|
exe_path TEXT,
|
|
window_title TEXT,
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT,
|
|
duration INTEGER DEFAULT 0,
|
|
FOREIGN KEY (project_id) REFERENCES projects(id)
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS calendar_sources (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
url TEXT,
|
|
last_synced TEXT,
|
|
sync_interval INTEGER DEFAULT 30,
|
|
enabled INTEGER DEFAULT 1,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS calendar_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_id INTEGER NOT NULL,
|
|
uid TEXT,
|
|
summary TEXT,
|
|
start_time TEXT,
|
|
end_time TEXT,
|
|
duration INTEGER DEFAULT 0,
|
|
location TEXT,
|
|
synced_at TEXT,
|
|
FOREIGN KEY (source_id) REFERENCES calendar_sources(id) ON DELETE CASCADE
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
// Migrate calendar_events table - add description column (safe to re-run)
|
|
let calendar_migrations = [
|
|
"ALTER TABLE calendar_events ADD COLUMN description TEXT",
|
|
];
|
|
for sql in &calendar_migrations {
|
|
match conn.execute(sql, []) {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
if !msg.contains("duplicate column") {
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS timesheet_locks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
week_start TEXT NOT NULL UNIQUE,
|
|
status TEXT DEFAULT 'locked',
|
|
locked_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS entry_templates (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
task_id INTEGER REFERENCES tasks(id),
|
|
description TEXT,
|
|
duration INTEGER NOT NULL DEFAULT 0,
|
|
billable INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS timesheet_rows (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
week_start TEXT NOT NULL,
|
|
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
task_id INTEGER REFERENCES tasks(id),
|
|
sort_order INTEGER NOT NULL DEFAULT 0
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS invoice_payments (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
invoice_id INTEGER NOT NULL,
|
|
amount REAL NOT NULL,
|
|
date TEXT NOT NULL,
|
|
method TEXT,
|
|
notes TEXT,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS recurring_invoices (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
client_id INTEGER NOT NULL,
|
|
template_id TEXT,
|
|
line_items_json TEXT NOT NULL,
|
|
tax_rate REAL DEFAULT 0,
|
|
discount REAL DEFAULT 0,
|
|
notes TEXT,
|
|
recurrence_rule TEXT NOT NULL,
|
|
next_due_date TEXT NOT NULL,
|
|
enabled INTEGER DEFAULT 1,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (client_id) REFERENCES clients(id)
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
// Insert default settings
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO settings (key, value) VALUES ('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')",
|
|
[],
|
|
)?;
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO settings (key, value) VALUES ('app_tracking_mode', 'auto')",
|
|
[],
|
|
)?;
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO settings (key, value) VALUES ('app_check_interval', '5')",
|
|
[],
|
|
)?;
|
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('goals_enabled', 'true')", [])?;
|
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('daily_goal_hours', '8')", [])?;
|
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('weekly_goal_hours', '40')", [])?;
|
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('rounding_enabled', 'false')", [])?;
|
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('rounding_increment', '15')", [])?;
|
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('rounding_method', 'nearest')", [])?;
|
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('theme_mode', 'dark')", [])?;
|
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('accent_color', 'amber')", [])?;
|
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('shortcut_toggle_timer', 'CmdOrCtrl+Shift+T')", [])?;
|
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('shortcut_show_app', 'CmdOrCtrl+Shift+Z')", [])?;
|
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('mini_timer_opacity', '90')", [])?;
|
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('timeline_recording', 'off')", [])?;
|
|
|
|
Ok(())
|
|
}
|