feat: tooltips, two-column timer, font selector, tray behavior, icons, readme

- 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
This commit is contained in:
Your Name
2026-02-21 01:15:57 +02:00
parent 2608f447de
commit 514090eed4
144 changed files with 13351 additions and 3456 deletions

View File

@@ -15,6 +15,7 @@ pub struct Client {
pub tax_id: Option<String>,
pub payment_terms: Option<String>,
pub notes: Option<String>,
pub currency: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -28,6 +29,8 @@ pub struct Project {
pub budget_hours: Option<f64>,
pub budget_amount: Option<f64>,
pub rounding_override: Option<i32>,
pub notes: Option<String>,
pub currency: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -36,6 +39,7 @@ pub struct Task {
pub project_id: i64,
pub name: String,
pub estimated_hours: Option<f64>,
pub hourly_rate: Option<f64>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -72,7 +76,7 @@ pub struct Invoice {
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, company, phone, tax_id, payment_terms, notes FROM clients ORDER BY name"
"SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes, currency FROM clients ORDER BY name"
).map_err(|e| e.to_string())?;
let clients = stmt.query_map([], |row| {
Ok(Client {
@@ -85,6 +89,7 @@ pub fn get_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
tax_id: row.get(6)?,
payment_terms: row.get(7)?,
notes: row.get(8)?,
currency: row.get(9)?,
})
}).map_err(|e| e.to_string())?;
clients.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
@@ -94,8 +99,8 @@ pub fn get_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
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, company, phone, tax_id, payment_terms, notes) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes],
"INSERT INTO clients (name, email, address, company, phone, tax_id, payment_terms, notes, currency) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes, client.currency],
).map_err(|e| e.to_string())?;
Ok(conn.last_insert_rowid())
}
@@ -104,8 +109,8 @@ pub fn create_client(state: State<AppState>, client: Client) -> Result<i64, Stri
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, company = ?4, phone = ?5, tax_id = ?6, payment_terms = ?7, notes = ?8 WHERE id = ?9",
params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes, client.id],
"UPDATE clients SET name = ?1, email = ?2, address = ?3, company = ?4, phone = ?5, tax_id = ?6, payment_terms = ?7, notes = ?8, currency = ?9 WHERE id = ?10",
params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes, client.currency, client.id],
).map_err(|e| e.to_string())?;
Ok(())
}
@@ -143,18 +148,23 @@ pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
};
for pid in &project_ids {
conn.execute("DELETE FROM timeline_events WHERE project_id = ?1", params![pid])?;
conn.execute("DELETE FROM invoice_items WHERE time_entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![pid])?;
conn.execute("DELETE FROM entry_tags WHERE entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![pid])?;
conn.execute("DELETE FROM time_entries WHERE project_id = ?1", params![pid])?;
conn.execute("DELETE FROM tasks WHERE project_id = ?1", params![pid])?;
conn.execute("DELETE FROM tracked_apps WHERE project_id = ?1", params![pid])?;
conn.execute("DELETE FROM favorites WHERE project_id = ?1", params![pid])?;
conn.execute("DELETE FROM recurring_entries WHERE project_id = ?1", params![pid])?;
conn.execute("DELETE FROM timeline_events WHERE project_id = ?1", params![pid])?;
conn.execute("DELETE FROM entry_templates WHERE project_id = ?1", params![pid])?;
conn.execute("DELETE FROM timesheet_rows WHERE project_id = ?1", params![pid])?;
conn.execute("DELETE FROM tasks WHERE project_id = ?1", params![pid])?;
}
conn.execute("DELETE FROM expenses WHERE client_id = ?1", params![id])?;
conn.execute("DELETE FROM invoice_payments WHERE invoice_id IN (SELECT id FROM invoices WHERE client_id = ?1)", params![id])?;
conn.execute("DELETE FROM invoice_items WHERE invoice_id IN (SELECT id FROM invoices WHERE client_id = ?1)", params![id])?;
conn.execute("DELETE FROM invoices WHERE client_id = ?1", params![id])?;
conn.execute("DELETE FROM recurring_invoices WHERE client_id = ?1", params![id])?;
conn.execute("DELETE FROM projects WHERE client_id = ?1", params![id])?;
conn.execute("DELETE FROM clients WHERE id = ?1", params![id])?;
Ok(())
@@ -177,7 +187,7 @@ pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
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, budget_hours, budget_amount, rounding_override FROM projects ORDER BY name"
"SELECT id, client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency FROM projects ORDER BY name"
).map_err(|e| e.to_string())?;
let projects = stmt.query_map([], |row| {
Ok(Project {
@@ -190,6 +200,8 @@ pub fn get_projects(state: State<AppState>) -> Result<Vec<Project>, String> {
budget_hours: row.get(6)?,
budget_amount: row.get(7)?,
rounding_override: row.get(8)?,
notes: row.get(9)?,
currency: row.get(10)?,
})
}).map_err(|e| e.to_string())?;
projects.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
@@ -199,8 +211,8 @@ pub fn get_projects(state: State<AppState>) -> Result<Vec<Project>, String> {
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, budget_hours, budget_amount, rounding_override) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32, project.budget_hours, project.budget_amount, project.rounding_override],
"INSERT INTO projects (client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32, project.budget_hours, project.budget_amount, project.rounding_override, project.notes, project.currency],
).map_err(|e| e.to_string())?;
Ok(conn.last_insert_rowid())
}
@@ -209,8 +221,8 @@ pub fn create_project(state: State<AppState>, project: Project) -> Result<i64, S
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, budget_hours = ?6, budget_amount = ?7, rounding_override = ?8 WHERE id = ?9",
params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32, project.budget_hours, project.budget_amount, project.rounding_override, project.id],
"UPDATE projects SET client_id = ?1, name = ?2, hourly_rate = ?3, color = ?4, archived = ?5, budget_hours = ?6, budget_amount = ?7, rounding_override = ?8, notes = ?9, currency = ?10 WHERE id = ?11",
params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32, project.budget_hours, project.budget_amount, project.rounding_override, project.notes, project.currency, project.id],
).map_err(|e| e.to_string())?;
Ok(())
}
@@ -255,6 +267,7 @@ pub fn delete_project(state: State<AppState>, id: i64) -> Result<(), String> {
let result = (|| -> Result<(), rusqlite::Error> {
conn.execute("DELETE FROM timeline_events WHERE project_id = ?1", params![id])?;
conn.execute("DELETE FROM invoice_items WHERE time_entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![id])?;
conn.execute(
"DELETE FROM entry_tags WHERE entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)",
params![id],
@@ -264,6 +277,8 @@ pub fn delete_project(state: State<AppState>, id: i64) -> Result<(), String> {
conn.execute("DELETE FROM expenses WHERE project_id = ?1", params![id])?;
conn.execute("DELETE FROM recurring_entries WHERE project_id = ?1", params![id])?;
conn.execute("DELETE FROM tracked_apps WHERE project_id = ?1", params![id])?;
conn.execute("DELETE FROM entry_templates WHERE project_id = ?1", params![id])?;
conn.execute("DELETE FROM timesheet_rows WHERE project_id = ?1", params![id])?;
conn.execute("DELETE FROM tasks WHERE project_id = ?1", params![id])?;
conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?;
Ok(())
@@ -285,13 +300,14 @@ pub fn delete_project(state: State<AppState>, id: i64) -> Result<(), String> {
#[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, estimated_hours FROM tasks WHERE project_id = ?1 ORDER BY name").map_err(|e| e.to_string())?;
let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours, hourly_rate 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)?,
estimated_hours: row.get(3)?,
hourly_rate: row.get(4)?,
})
}).map_err(|e| e.to_string())?;
tasks.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
@@ -301,8 +317,8 @@ pub fn get_tasks(state: State<AppState>, project_id: i64) -> Result<Vec<Task>, S
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, estimated_hours) VALUES (?1, ?2, ?3)",
params![task.project_id, task.name, task.estimated_hours],
"INSERT INTO tasks (project_id, name, estimated_hours, hourly_rate) VALUES (?1, ?2, ?3, ?4)",
params![task.project_id, task.name, task.estimated_hours, task.hourly_rate],
).map_err(|e| e.to_string())?;
Ok(conn.last_insert_rowid())
}
@@ -310,6 +326,9 @@ pub fn create_task(state: State<AppState>, task: Task) -> Result<i64, String> {
#[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 entry_templates WHERE task_id = ?1", params![id]).map_err(|e| e.to_string())?;
conn.execute("DELETE FROM timesheet_rows WHERE task_id = ?1", params![id]).map_err(|e| e.to_string())?;
conn.execute("DELETE FROM recurring_entries WHERE task_id = ?1", params![id]).map_err(|e| e.to_string())?;
conn.execute("DELETE FROM tasks WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
Ok(())
}
@@ -318,8 +337,8 @@ pub fn delete_task(state: State<AppState>, id: i64) -> Result<(), String> {
pub fn update_task(state: State<AppState>, task: Task) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute(
"UPDATE tasks SET name = ?1, estimated_hours = ?2 WHERE id = ?3",
params![task.name, task.estimated_hours, task.id],
"UPDATE tasks SET name = ?1, estimated_hours = ?2, hourly_rate = ?3 WHERE id = ?4",
params![task.name, task.estimated_hours, task.hourly_rate, task.id],
).map_err(|e| e.to_string())?;
Ok(())
}
@@ -442,6 +461,8 @@ pub fn delete_time_entry(state: State<AppState>, id: i64) -> Result<(), String>
if locked {
return Err("Cannot modify entries in a locked week".to_string());
}
conn.execute("DELETE FROM invoice_items WHERE time_entry_id = ?1", params![id]).map_err(|e| e.to_string())?;
conn.execute("DELETE FROM entry_tags WHERE entry_id = ?1", params![id]).map_err(|e| e.to_string())?;
conn.execute("DELETE FROM time_entries WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
Ok(())
}
@@ -543,6 +564,7 @@ pub fn update_invoice(state: State<AppState>, invoice: Invoice) -> Result<(), St
#[tauri::command]
pub fn delete_invoice(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM invoice_payments WHERE invoice_id = ?1", params![id]).map_err(|e| e.to_string())?;
conn.execute("DELETE FROM invoice_items WHERE invoice_id = ?1", params![id]).map_err(|e| e.to_string())?;
conn.execute("DELETE FROM invoices WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
Ok(())
@@ -681,7 +703,7 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
let conn = state.db.lock().map_err(|e| e.to_string())?;
let clients = {
let mut stmt = conn.prepare("SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes FROM clients").map_err(|e| e.to_string())?;
let mut stmt = conn.prepare("SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes, currency FROM clients").map_err(|e| e.to_string())?;
let rows: Vec<serde_json::Value> = stmt.query_map([], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
@@ -692,14 +714,15 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
"phone": row.get::<_, Option<String>>(5)?,
"tax_id": row.get::<_, Option<String>>(6)?,
"payment_terms": row.get::<_, Option<String>>(7)?,
"notes": row.get::<_, Option<String>>(8)?
"notes": row.get::<_, Option<String>>(8)?,
"currency": row.get::<_, Option<String>>(9)?
}))
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
rows
};
let projects = {
let mut stmt = conn.prepare("SELECT id, client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override FROM projects").map_err(|e| e.to_string())?;
let mut stmt = conn.prepare("SELECT id, client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency FROM projects").map_err(|e| e.to_string())?;
let rows: Vec<serde_json::Value> = stmt.query_map([], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
@@ -710,20 +733,23 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
"archived": row.get::<_, i32>(5)? != 0,
"budget_hours": row.get::<_, Option<f64>>(6)?,
"budget_amount": row.get::<_, Option<f64>>(7)?,
"rounding_override": row.get::<_, Option<i32>>(8)?
"rounding_override": row.get::<_, Option<i32>>(8)?,
"notes": row.get::<_, Option<String>>(9)?,
"currency": row.get::<_, Option<String>>(10)?
}))
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
rows
};
let tasks = {
let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours FROM tasks").map_err(|e| e.to_string())?;
let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours, hourly_rate FROM tasks").map_err(|e| e.to_string())?;
let rows: Vec<serde_json::Value> = stmt.query_map([], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
"project_id": row.get::<_, i64>(1)?,
"name": row.get::<_, String>(2)?,
"estimated_hours": row.get::<_, Option<f64>>(3)?
"estimated_hours": row.get::<_, Option<f64>>(3)?,
"hourly_rate": row.get::<_, Option<f64>>(4)?
}))
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
rows
@@ -1004,13 +1030,26 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
pub fn clear_all_data(state: State<AppState>) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute_batch(
"DELETE FROM tracked_apps;
"DELETE FROM entry_tags;
DELETE FROM invoice_payments;
DELETE FROM invoice_items;
DELETE FROM recurring_invoices;
DELETE FROM invoices;
DELETE FROM favorites;
DELETE FROM recurring_entries;
DELETE FROM entry_templates;
DELETE FROM timesheet_rows;
DELETE FROM timesheet_locks;
DELETE FROM timeline_events;
DELETE FROM expenses;
DELETE FROM tracked_apps;
DELETE FROM time_entries;
DELETE FROM tasks;
DELETE FROM projects;
DELETE FROM clients;"
DELETE FROM clients;
DELETE FROM tags;
DELETE FROM calendar_events;
DELETE FROM calendar_sources;"
).map_err(|e| e.to_string())?;
Ok(())
}
@@ -1669,7 +1708,7 @@ pub fn get_goal_progress(state: State<AppState>, today: String) -> Result<serde_
}))
}
// Profitability report command
// Profitability report command - includes expenses for net profit
#[tauri::command]
pub fn get_profitability_report(state: State<AppState>, start_date: String, end_date: String) -> Result<Vec<serde_json::Value>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
@@ -1687,7 +1726,8 @@ pub fn get_profitability_report(state: State<AppState>, start_date: String, end_
ORDER BY total_seconds DESC"
).map_err(|e| e.to_string())?;
let rows = stmt.query_map(params![start_date, end_date], |row| {
let rows: Vec<serde_json::Value> = stmt.query_map(params![start_date, end_date], |row| {
let project_id: i64 = row.get(0)?;
let total_seconds: i64 = row.get(7)?;
let hourly_rate: f64 = row.get(3)?;
let hours = total_seconds as f64 / 3600.0;
@@ -1696,22 +1736,38 @@ pub fn get_profitability_report(state: State<AppState>, start_date: String, end_
let budget_amount: Option<f64> = row.get(5)?;
Ok(serde_json::json!({
"project_id": row.get::<_, i64>(0)?,
"project_id": project_id,
"project_name": row.get::<_, String>(1)?,
"color": row.get::<_, String>(2)?,
"hourly_rate": hourly_rate,
"client_name": row.get::<_, Option<String>>(6)?,
"total_seconds": total_seconds,
"hours": hours,
"total_hours": hours,
"revenue": revenue,
"budget_hours": budget_hours,
"budget_amount": budget_amount,
"percent_hours": budget_hours.map(|b| if b > 0.0 { (hours / b) * 100.0 } else { 0.0 }),
"budget_used_pct": budget_hours.map(|b| if b > 0.0 { (hours / b) * 100.0 } else { 0.0 }),
"percent_amount": budget_amount.map(|b| if b > 0.0 { (revenue / b) * 100.0 } else { 0.0 })
}))
}).map_err(|e| e.to_string())?;
}).map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
// Add expense totals per project for the date range
let mut result: Vec<serde_json::Value> = Vec::new();
for mut row in rows {
let pid = row["project_id"].as_i64().unwrap_or(0);
let expense_total: f64 = conn.query_row(
"SELECT COALESCE(SUM(amount), 0) FROM expenses WHERE project_id = ?1 AND date >= ?2 AND date <= ?3",
params![pid, start_date, end_date],
|r| r.get(0),
).unwrap_or(0.0);
let revenue = row["revenue"].as_f64().unwrap_or(0.0);
row.as_object_mut().unwrap().insert("expenses".into(), serde_json::json!(expense_total));
row.as_object_mut().unwrap().insert("net_profit".into(), serde_json::json!(revenue - expense_total));
result.push(row);
}
Ok(result)
}
// Timesheet data command
@@ -2239,6 +2295,392 @@ pub fn auto_backup(state: State<AppState>, backup_dir: String) -> Result<String,
Ok(path.to_string_lossy().to_string())
}
#[tauri::command]
pub fn list_backup_files(backup_dir: String) -> Result<Vec<serde_json::Value>, String> {
let dir = std::path::Path::new(&backup_dir);
if !dir.exists() {
return Ok(Vec::new());
}
let mut files: Vec<serde_json::Value> = std::fs::read_dir(dir)
.map_err(|e| e.to_string())?
.flatten()
.filter(|e| {
e.path().extension().and_then(|ext| ext.to_str()) == Some("json")
&& e.file_name().to_string_lossy().starts_with("zeroclock-backup-")
})
.filter_map(|e| {
let meta = e.metadata().ok()?;
let modified = meta.modified().ok()?;
Some(serde_json::json!({
"path": e.path().to_string_lossy().to_string(),
"name": e.file_name().to_string_lossy().to_string(),
"size": meta.len(),
"modified": modified.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(),
}))
})
.collect();
files.sort_by(|a, b| {
b.get("modified").and_then(|v| v.as_u64())
.cmp(&a.get("modified").and_then(|v| v.as_u64()))
});
Ok(files)
}
#[tauri::command]
pub fn delete_backup_file(path: String) -> Result<(), String> {
std::fs::remove_file(&path).map_err(|e| e.to_string())
}
// Get recent unique descriptions for autocomplete
#[tauri::command]
pub fn get_recent_descriptions(state: State<AppState>) -> Result<Vec<String>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare(
"SELECT description, COUNT(*) as cnt FROM time_entries
WHERE description IS NOT NULL AND description != ''
GROUP BY description ORDER BY cnt DESC LIMIT 50"
).map_err(|e| e.to_string())?;
let rows = stmt.query_map([], |row| {
row.get::<_, String>(0)
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
// Check for overlapping time entries
#[tauri::command]
pub fn check_entry_overlap(
state: State<AppState>,
start_time: String,
end_time: String,
exclude_id: Option<i64>,
) -> Result<Vec<serde_json::Value>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let query = if let Some(eid) = exclude_id {
let mut stmt = conn.prepare(
"SELECT te.id, te.description, te.start_time, te.end_time, p.name as project_name
FROM time_entries te
JOIN projects p ON te.project_id = p.id
WHERE te.end_time IS NOT NULL
AND te.id != ?3
AND te.start_time < ?2
AND te.end_time > ?1
ORDER BY te.start_time"
).map_err(|e| e.to_string())?;
let rows = stmt.query_map(params![start_time, end_time, eid], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
"description": row.get::<_, Option<String>>(1)?,
"start_time": row.get::<_, String>(2)?,
"end_time": row.get::<_, Option<String>>(3)?,
"project_name": row.get::<_, String>(4)?
}))
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
} else {
let mut stmt = conn.prepare(
"SELECT te.id, te.description, te.start_time, te.end_time, p.name as project_name
FROM time_entries te
JOIN projects p ON te.project_id = p.id
WHERE te.end_time IS NOT NULL
AND te.start_time < ?2
AND te.end_time > ?1
ORDER BY te.start_time"
).map_err(|e| e.to_string())?;
let rows = stmt.query_map(params![start_time, end_time], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
"description": row.get::<_, Option<String>>(1)?,
"start_time": row.get::<_, String>(2)?,
"end_time": row.get::<_, Option<String>>(3)?,
"project_name": row.get::<_, String>(4)?
}))
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
};
Ok(query)
}
// Get actual hours by task for a project (estimates vs actuals)
#[tauri::command]
pub fn get_task_actuals(state: State<AppState>, project_id: i64) -> Result<Vec<serde_json::Value>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare(
"SELECT t.id, t.name, t.estimated_hours, t.hourly_rate,
COALESCE(SUM(te.duration), 0) as actual_seconds
FROM tasks t
LEFT JOIN time_entries te ON te.task_id = t.id
WHERE t.project_id = ?1
GROUP BY t.id
ORDER BY t.name"
).map_err(|e| e.to_string())?;
let rows = stmt.query_map(params![project_id], |row| {
let estimated: Option<f64> = row.get(2)?;
let actual_seconds: i64 = row.get(4)?;
let actual_hours = actual_seconds as f64 / 3600.0;
let variance = estimated.map(|est| actual_hours - est);
let progress = estimated.map(|est| if est > 0.0 { (actual_hours / est) * 100.0 } else { 0.0 });
Ok(serde_json::json!({
"task_id": row.get::<_, i64>(0)?,
"task_name": row.get::<_, String>(1)?,
"estimated_hours": estimated,
"hourly_rate": row.get::<_, Option<f64>>(3)?,
"actual_seconds": actual_seconds,
"actual_hours": actual_hours,
"variance": variance,
"progress": progress
}))
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
// Invoice payment struct and commands
#[derive(Debug, Serialize, Deserialize)]
pub struct InvoicePayment {
pub id: Option<i64>,
pub invoice_id: i64,
pub amount: f64,
pub date: String,
pub method: Option<String>,
pub notes: Option<String>,
}
#[tauri::command]
pub fn get_invoice_payments(state: State<AppState>, invoice_id: i64) -> Result<Vec<InvoicePayment>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare(
"SELECT id, invoice_id, amount, date, method, notes FROM invoice_payments WHERE invoice_id = ?1 ORDER BY date"
).map_err(|e| e.to_string())?;
let payments = stmt.query_map(params![invoice_id], |row| {
Ok(InvoicePayment {
id: Some(row.get(0)?),
invoice_id: row.get(1)?,
amount: row.get(2)?,
date: row.get(3)?,
method: row.get(4)?,
notes: row.get(5)?,
})
}).map_err(|e| e.to_string())?;
payments.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn add_invoice_payment(state: State<AppState>, payment: InvoicePayment) -> Result<i64, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO invoice_payments (invoice_id, amount, date, method, notes) VALUES (?1, ?2, ?3, ?4, ?5)",
params![payment.invoice_id, payment.amount, payment.date, payment.method, payment.notes],
).map_err(|e| e.to_string())?;
// Update invoice status based on total paid
let total_paid: f64 = conn.query_row(
"SELECT COALESCE(SUM(amount), 0) FROM invoice_payments WHERE invoice_id = ?1",
params![payment.invoice_id],
|row| row.get(0),
).map_err(|e| e.to_string())?;
let invoice_total: f64 = conn.query_row(
"SELECT total FROM invoices WHERE id = ?1",
params![payment.invoice_id],
|row| row.get(0),
).map_err(|e| e.to_string())?;
let new_status = if total_paid >= invoice_total { "paid" } else { "partial" };
conn.execute(
"UPDATE invoices SET status = ?1 WHERE id = ?2",
params![new_status, payment.invoice_id],
).map_err(|e| e.to_string())?;
Ok(conn.last_insert_rowid())
}
#[tauri::command]
pub fn delete_invoice_payment(state: State<AppState>, id: i64, invoice_id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM invoice_payments WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
// Recalculate invoice status
let total_paid: f64 = conn.query_row(
"SELECT COALESCE(SUM(amount), 0) FROM invoice_payments WHERE invoice_id = ?1",
params![invoice_id],
|row| row.get(0),
).map_err(|e| e.to_string())?;
let invoice_total: f64 = conn.query_row(
"SELECT total FROM invoices WHERE id = ?1",
params![invoice_id],
|row| row.get(0),
).map_err(|e| e.to_string())?;
let new_status = if total_paid >= invoice_total {
"paid"
} else if total_paid > 0.0 {
"partial"
} else {
"sent"
};
conn.execute(
"UPDATE invoices SET status = ?1 WHERE id = ?2",
params![new_status, invoice_id],
).map_err(|e| e.to_string())?;
Ok(())
}
// Recurring invoice struct and commands
#[derive(Debug, Serialize, Deserialize)]
pub struct RecurringInvoice {
pub id: Option<i64>,
pub client_id: i64,
pub template_id: Option<String>,
pub line_items_json: String,
pub tax_rate: f64,
pub discount: f64,
pub notes: Option<String>,
pub recurrence_rule: String,
pub next_due_date: String,
pub enabled: Option<i64>,
}
#[tauri::command]
pub fn get_recurring_invoices(state: State<AppState>) -> Result<Vec<RecurringInvoice>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare(
"SELECT id, client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled
FROM recurring_invoices ORDER BY next_due_date"
).map_err(|e| e.to_string())?;
let rows = stmt.query_map([], |row| {
Ok(RecurringInvoice {
id: Some(row.get(0)?),
client_id: row.get(1)?,
template_id: row.get(2)?,
line_items_json: row.get(3)?,
tax_rate: row.get(4)?,
discount: row.get(5)?,
notes: row.get(6)?,
recurrence_rule: row.get(7)?,
next_due_date: row.get(8)?,
enabled: row.get(9)?,
})
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn create_recurring_invoice(state: State<AppState>, invoice: RecurringInvoice) -> Result<i64, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO recurring_invoices (client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![invoice.client_id, invoice.template_id, invoice.line_items_json, invoice.tax_rate,
invoice.discount, invoice.notes, invoice.recurrence_rule, invoice.next_due_date, invoice.enabled.unwrap_or(1)],
).map_err(|e| e.to_string())?;
Ok(conn.last_insert_rowid())
}
#[tauri::command]
pub fn update_recurring_invoice(state: State<AppState>, invoice: RecurringInvoice) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute(
"UPDATE recurring_invoices SET client_id = ?1, template_id = ?2, line_items_json = ?3,
tax_rate = ?4, discount = ?5, notes = ?6, recurrence_rule = ?7, next_due_date = ?8, enabled = ?9
WHERE id = ?10",
params![invoice.client_id, invoice.template_id, invoice.line_items_json, invoice.tax_rate,
invoice.discount, invoice.notes, invoice.recurrence_rule, invoice.next_due_date, invoice.enabled, invoice.id],
).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn delete_recurring_invoice(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM recurring_invoices WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
// Check recurring invoices and auto-create drafts when due
#[tauri::command]
pub fn check_recurring_invoices(state: State<AppState>, today: String) -> Result<Vec<i64>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare(
"SELECT id, client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date
FROM recurring_invoices WHERE enabled = 1 AND date(next_due_date) <= date(?1)"
).map_err(|e| e.to_string())?;
let due: Vec<(i64, i64, Option<String>, String, f64, f64, Option<String>, String, String)> = stmt
.query_map(params![today], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?,
row.get(5)?, row.get(6)?, row.get(7)?, row.get(8)?))
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
let mut created_ids: Vec<i64> = Vec::new();
for (ri_id, client_id, template_id, line_items_json, tax_rate, discount, notes, rule, next_due) in &due {
// Generate invoice number
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM invoices", [], |row| row.get(0)
).map_err(|e| e.to_string())?;
let inv_number = format!("INV-{:04}", count + 1);
// Parse line items to calculate totals
let items: Vec<serde_json::Value> = serde_json::from_str(line_items_json).unwrap_or_default();
let subtotal: f64 = items.iter().map(|item| {
let qty = item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(0.0);
let rate = item.get("unit_price").and_then(|v| v.as_f64()).unwrap_or(0.0);
qty * rate
}).sum();
let tax_amount = subtotal * tax_rate / 100.0;
let total = subtotal + tax_amount - discount;
conn.execute(
"INSERT INTO invoices (client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'draft', ?11)",
params![client_id, inv_number, next_due, next_due, subtotal, tax_rate, tax_amount, discount, total, notes, template_id],
).map_err(|e| e.to_string())?;
let invoice_id = conn.last_insert_rowid();
// Insert line items
for item in &items {
let desc = item.get("description").and_then(|v| v.as_str()).unwrap_or("");
let qty = item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(0.0);
let rate = item.get("unit_price").and_then(|v| v.as_f64()).unwrap_or(0.0);
let amount = qty * rate;
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount) VALUES (?1, ?2, ?3, ?4, ?5)",
params![invoice_id, desc, qty, rate, amount],
).map_err(|e| e.to_string())?;
}
created_ids.push(invoice_id);
// Advance next_due_date based on recurrence rule
let next: String = match rule.as_str() {
"weekly" => conn.query_row(
"SELECT date(?1, '+7 days')", params![next_due], |row| row.get(0)
).map_err(|e| e.to_string())?,
"biweekly" => conn.query_row(
"SELECT date(?1, '+14 days')", params![next_due], |row| row.get(0)
).map_err(|e| e.to_string())?,
"quarterly" => conn.query_row(
"SELECT date(?1, '+3 months')", params![next_due], |row| row.get(0)
).map_err(|e| e.to_string())?,
"yearly" => conn.query_row(
"SELECT date(?1, '+1 year')", params![next_due], |row| row.get(0)
).map_err(|e| e.to_string())?,
_ => conn.query_row(
"SELECT date(?1, '+1 month')", params![next_due], |row| row.get(0)
).map_err(|e| e.to_string())?,
};
conn.execute(
"UPDATE recurring_invoices SET next_due_date = ?1 WHERE id = ?2",
params![next, ri_id],
).map_err(|e| e.to_string())?;
}
Ok(created_ids)
}
pub fn seed_default_templates(data_dir: &std::path::Path) {
let templates_dir = data_dir.join("templates");
std::fs::create_dir_all(&templates_dir).ok();
@@ -2296,6 +2738,8 @@ struct ParsedCalendarEvent {
start_time: Option<String>,
end_time: Option<String>,
location: Option<String>,
description: Option<String>,
duration: i64,
}
fn parse_ics_datetime(dt: &str) -> Option<String> {
@@ -2323,7 +2767,67 @@ fn parse_ics_datetime(dt: &str) -> Option<String> {
}
}
fn unfold_ics_lines(content: &str) -> String {
let mut result = String::new();
for line in content.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with(' ') || line.starts_with('\t') {
result.push_str(line.trim_start());
} else {
if !result.is_empty() {
result.push('\n');
}
result.push_str(line);
}
}
result
}
fn parse_ics_duration(dur: &str) -> Option<i64> {
let dur = dur.strip_prefix("PT")?;
let mut seconds: i64 = 0;
let mut num_buf = String::new();
for ch in dur.chars() {
if ch.is_ascii_digit() {
num_buf.push(ch);
} else {
let n: i64 = num_buf.parse().ok()?;
num_buf.clear();
match ch {
'H' => seconds += n * 3600,
'M' => seconds += n * 60,
'S' => seconds += n,
_ => {}
}
}
}
Some(seconds)
}
fn calc_ics_duration_from_times(start: &str, end: &str) -> i64 {
let parse_ts = |s: &str| -> Option<i64> {
let s = s.trim();
if s.len() >= 15 {
let year: i64 = s[0..4].parse().ok()?;
let month: i64 = s[4..6].parse().ok()?;
let day: i64 = s[6..8].parse().ok()?;
let hour: i64 = s[9..11].parse().ok()?;
let min: i64 = s[11..13].parse().ok()?;
let sec: i64 = s[13..15].parse().ok()?;
// Approximate seconds since epoch (good enough for duration calc)
Some(((year * 365 + month * 30 + day) * 86400) + hour * 3600 + min * 60 + sec)
} else {
None
}
};
match (parse_ts(start), parse_ts(end)) {
(Some(s), Some(e)) if e > s => e - s,
_ => 0,
}
}
fn parse_ics_content(content: &str) -> Vec<ParsedCalendarEvent> {
let unfolded = unfold_ics_lines(content);
let mut events = Vec::new();
let mut in_event = false;
let mut uid = String::new();
@@ -2331,9 +2835,10 @@ fn parse_ics_content(content: &str) -> Vec<ParsedCalendarEvent> {
let mut dtstart = String::new();
let mut dtend = String::new();
let mut location = String::new();
let mut description = String::new();
let mut duration_str = String::new();
for line in content.lines() {
let line = line.trim_end_matches('\r');
for line in unfolded.lines() {
if line == "BEGIN:VEVENT" {
in_event = true;
uid.clear();
@@ -2341,22 +2846,25 @@ fn parse_ics_content(content: &str) -> Vec<ParsedCalendarEvent> {
dtstart.clear();
dtend.clear();
location.clear();
description.clear();
duration_str.clear();
} else if line == "END:VEVENT" {
if in_event {
let duration = if !duration_str.is_empty() {
parse_ics_duration(&duration_str).unwrap_or(0)
} else if !dtstart.is_empty() && !dtend.is_empty() {
calc_ics_duration_from_times(&dtstart, &dtend)
} else {
0
};
events.push(ParsedCalendarEvent {
uid: if uid.is_empty() { None } else { Some(uid.clone()) },
summary: if summary.is_empty() {
None
} else {
Some(summary.clone())
},
summary: if summary.is_empty() { None } else { Some(summary.clone()) },
start_time: parse_ics_datetime(&dtstart),
end_time: parse_ics_datetime(&dtend),
location: if location.is_empty() {
None
} else {
Some(location.clone())
},
location: if location.is_empty() { None } else { Some(location.clone()) },
description: if description.is_empty() { None } else { Some(description.clone()) },
duration,
});
}
in_event = false;
@@ -2375,6 +2883,12 @@ fn parse_ics_content(content: &str) -> Vec<ParsedCalendarEvent> {
}
} else if let Some(val) = line.strip_prefix("LOCATION:") {
location = val.to_string();
} else if let Some(val) = line.strip_prefix("DESCRIPTION:") {
description = val.replace("\\n", "\n").replace("\\,", ",");
} else if line.starts_with("DURATION") {
if let Some(idx) = line.find(':') {
duration_str = line[idx + 1..].to_string();
}
}
}
}
@@ -2485,15 +2999,17 @@ pub fn import_ics_file(
}
conn.execute(
"INSERT INTO calendar_events (source_id, uid, summary, start_time, end_time, duration, location, synced_at)
VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7)",
"INSERT INTO calendar_events (source_id, uid, summary, start_time, end_time, duration, location, description, synced_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
source_id,
event.uid,
event.summary,
event.start_time,
event.end_time,
event.duration,
event.location,
event.description,
now
],
)
@@ -2710,6 +3226,33 @@ pub fn get_time_entries_paginated(
})
}
#[tauri::command]
pub fn search_entries(state: State<AppState>, query: String, limit: Option<i64>) -> Result<Vec<serde_json::Value>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let limit = limit.unwrap_or(10);
let pattern = format!("%{}%", query);
let mut stmt = conn.prepare(
"SELECT te.id, te.project_id, te.description, te.start_time, te.duration, p.name as project_name, p.color as project_color
FROM time_entries te
LEFT JOIN projects p ON te.project_id = p.id
WHERE te.description LIKE ?1
ORDER BY te.start_time DESC
LIMIT ?2"
).map_err(|e| e.to_string())?;
let rows = stmt.query_map(params![pattern, limit], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
"project_id": row.get::<_, i64>(1)?,
"description": row.get::<_, Option<String>>(2)?,
"start_time": row.get::<_, String>(3)?,
"duration": row.get::<_, i64>(4)?,
"project_name": row.get::<_, Option<String>>(5)?,
"project_color": row.get::<_, Option<String>>(6)?,
}))
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn bulk_delete_entries(state: State<AppState>, ids: Vec<i64>) -> Result<(), String> {
if ids.is_empty() { return Ok(()); }
@@ -2718,6 +3261,7 @@ pub fn bulk_delete_entries(state: State<AppState>, ids: Vec<i64>) -> Result<(),
let result = (|| -> Result<(), rusqlite::Error> {
for id in &ids {
conn.execute("DELETE FROM invoice_items WHERE time_entry_id = ?1", params![id])?;
conn.execute("DELETE FROM entry_tags WHERE entry_id = ?1", params![id])?;
conn.execute("DELETE FROM time_entries WHERE id = ?1", params![id])?;
}
@@ -2852,6 +3396,23 @@ pub fn delete_entry_template(state: State<AppState>, id: i64) -> Result<(), Stri
Ok(())
}
#[tauri::command]
pub fn update_entry_template(state: State<AppState>, template: serde_json::Value) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let id = template.get("id").and_then(|v| v.as_i64()).ok_or("id required")?;
let name = template.get("name").and_then(|v| v.as_str()).unwrap_or("Untitled");
let project_id = template.get("project_id").and_then(|v| v.as_i64()).ok_or("project_id required")?;
let task_id = template.get("task_id").and_then(|v| v.as_i64());
let description = template.get("description").and_then(|v| v.as_str());
let duration = template.get("duration").and_then(|v| v.as_i64()).unwrap_or(0);
let billable = template.get("billable").and_then(|v| v.as_i64()).unwrap_or(1);
conn.execute(
"UPDATE entry_templates SET name=?1, project_id=?2, task_id=?3, description=?4, duration=?5, billable=?6 WHERE id=?7",
params![name, project_id, task_id, description, duration, billable, id],
).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn get_timesheet_rows(state: State<AppState>, week_start: String) -> Result<Vec<serde_json::Value>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
@@ -3080,3 +3641,10 @@ fn get_default_templates() -> Vec<InvoiceTemplate> {
},
]
}
#[tauri::command]
pub fn seed_sample_data(state: State<AppState>) -> Result<String, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
crate::seed::seed(&conn)?;
Ok("Sample data loaded".to_string())
}