From c6cb26553adf1c257850b17f34861a5194129f4f Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 02:04:10 +0200 Subject: [PATCH] feat: add goals, profitability, timesheet, and import commands --- src-tauri/src/commands.rs | 317 ++++++++++++++++++++++++++++++++++++++ src-tauri/src/database.rs | 11 ++ src-tauri/src/lib.rs | 5 + 3 files changed, 333 insertions(+) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b6b0915..a6489d2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -735,3 +735,320 @@ pub fn reorder_favorites(state: State, ids: Vec) -> Result<(), St } Ok(()) } + +// Goals command +#[tauri::command] +pub fn get_goal_progress(state: State, today: String) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + + // Today's total seconds + let today_seconds: i64 = conn.query_row( + "SELECT COALESCE(SUM(duration), 0) FROM time_entries WHERE date(start_time) = date(?1)", + params![today], + |row| row.get(0), + ).map_err(|e| e.to_string())?; + + // This week's total seconds (Monday to Sunday, ISO week) + let week_seconds: i64 = conn.query_row( + "SELECT COALESCE(SUM(duration), 0) FROM time_entries + WHERE strftime('%Y-%W', start_time) = strftime('%Y-%W', ?1)", + params![today], + |row| row.get(0), + ).map_err(|e| e.to_string())?; + + // Streak: count consecutive days with entries going backwards from today + let mut streak_days: i64 = 0; + let mut check_date = today.clone(); + loop { + let has_entry: i64 = conn.query_row( + "SELECT COUNT(*) FROM time_entries WHERE date(start_time) = date(?1)", + params![check_date], + |row| row.get(0), + ).map_err(|e| e.to_string())?; + if has_entry > 0 { + streak_days += 1; + // Go to previous day + check_date = conn.query_row( + "SELECT date(?1, '-1 day')", + params![check_date], + |row| row.get::<_, String>(0), + ).map_err(|e| e.to_string())?; + } else { + break; + } + } + + Ok(serde_json::json!({ + "today_seconds": today_seconds, + "week_seconds": week_seconds, + "streak_days": streak_days + })) +} + +// Profitability report command +#[tauri::command] +pub fn get_profitability_report(state: State, start_date: String, end_date: String) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + + let mut stmt = conn.prepare( + "SELECT p.id, p.name, p.color, p.hourly_rate, p.budget_hours, p.budget_amount, + c.name as client_name, + COALESCE(SUM(t.duration), 0) as total_seconds + FROM projects p + LEFT JOIN clients c ON p.client_id = c.id + LEFT JOIN time_entries t ON t.project_id = p.id + AND date(t.start_time) >= date(?1) + AND date(t.start_time) <= date(?2) + GROUP BY p.id + ORDER BY total_seconds DESC" + ).map_err(|e| e.to_string())?; + + let rows = stmt.query_map(params![start_date, end_date], |row| { + let total_seconds: i64 = row.get(7)?; + let hourly_rate: f64 = row.get(3)?; + let hours = total_seconds as f64 / 3600.0; + let revenue = hours * hourly_rate; + let budget_hours: Option = row.get(4)?; + let budget_amount: Option = row.get(5)?; + + Ok(serde_json::json!({ + "project_id": row.get::<_, i64>(0)?, + "project_name": row.get::<_, String>(1)?, + "color": row.get::<_, String>(2)?, + "hourly_rate": hourly_rate, + "client_name": row.get::<_, Option>(6)?, + "total_seconds": total_seconds, + "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 }), + "percent_amount": budget_amount.map(|b| if b > 0.0 { (revenue / b) * 100.0 } else { 0.0 }) + })) + }).map_err(|e| e.to_string())?; + + rows.collect::, _>>().map_err(|e| e.to_string()) +} + +// Timesheet data command +#[tauri::command] +pub fn get_timesheet_data(state: State, week_start: String) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + + // Get the 7 days of the week + let mut days: Vec = Vec::new(); + for i in 0..7 { + let day: String = conn.query_row( + &format!("SELECT date(?1, '+{} days')", i), + params![week_start], + |row| row.get(0), + ).map_err(|e| e.to_string())?; + days.push(day); + } + + let mut stmt = conn.prepare( + "SELECT p.id, p.name, p.color, t2.id as task_id, t2.name as task_name, + date(te.start_time) as entry_date, COALESCE(SUM(te.duration), 0) as total_seconds + FROM time_entries te + JOIN projects p ON te.project_id = p.id + LEFT JOIN tasks t2 ON te.task_id = t2.id + WHERE date(te.start_time) >= date(?1) + AND date(te.start_time) < date(?1, '+7 days') + GROUP BY p.id, t2.id, date(te.start_time) + ORDER BY p.name, t2.name" + ).map_err(|e| e.to_string())?; + + let raw_rows: Vec<(i64, String, String, Option, Option, String, i64)> = stmt + .query_map(params![week_start], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) + }) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + + // Group by project+task + let mut grouped: std::collections::HashMap<(i64, Option), serde_json::Value> = std::collections::HashMap::new(); + for (project_id, project_name, color, task_id, task_name, entry_date, total_seconds) in &raw_rows { + let key = (*project_id, *task_id); + let entry = grouped.entry(key).or_insert_with(|| { + serde_json::json!({ + "project_id": project_id, + "project_name": project_name, + "color": color, + "task_id": task_id, + "task_name": task_name, + "days": [0i64; 7] + }) + }); + // Find which day index this entry_date corresponds to + if let Some(day_idx) = days.iter().position(|d| d == entry_date) { + if let Some(arr) = entry.get_mut("days").and_then(|d| d.as_array_mut()) { + arr[day_idx] = serde_json::json!(total_seconds); + } + } + } + + Ok(grouped.into_values().collect()) +} + +// Import entries command (CSV-style: individual entries) +#[tauri::command] +pub fn import_entries(state: State, entries: Vec) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let mut count: i64 = 0; + + for entry in entries { + let project_name = entry.get("project").and_then(|v| v.as_str()).unwrap_or("Imported"); + let description = entry.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let start_time = entry.get("start_time").and_then(|v| v.as_str()).unwrap_or(""); + let end_time = entry.get("end_time").and_then(|v| v.as_str()); + let duration = entry.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); + + if start_time.is_empty() { + continue; + } + + // Find or create project + let project_id: i64 = match conn.query_row( + "SELECT id FROM projects WHERE name = ?1", + params![project_name], + |row| row.get(0), + ) { + Ok(id) => id, + Err(_) => { + conn.execute( + "INSERT INTO projects (name, hourly_rate, color, archived) VALUES (?1, 0, '#F59E0B', 0)", + params![project_name], + ).map_err(|e| e.to_string())?; + conn.last_insert_rowid() + } + }; + + conn.execute( + "INSERT INTO time_entries (project_id, description, start_time, end_time, duration) VALUES (?1, ?2, ?3, ?4, ?5)", + params![project_id, description, start_time, end_time, duration], + ).map_err(|e| e.to_string())?; + count += 1; + } + + Ok(count) +} + +// Import full JSON data (clients, projects, tasks, entries, tags) +#[tauri::command] +pub fn import_json_data(state: State, data: serde_json::Value) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let mut counts = serde_json::json!({ + "clients": 0, + "projects": 0, + "tasks": 0, + "entries": 0, + "tags": 0 + }); + + // Import clients + if let Some(clients) = data.get("clients").and_then(|v| v.as_array()) { + for client in clients { + let name = client.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if name.is_empty() { continue; } + let email = client.get("email").and_then(|v| v.as_str()); + let address = client.get("address").and_then(|v| v.as_str()); + conn.execute( + "INSERT OR IGNORE INTO clients (name, email, address) VALUES (?1, ?2, ?3)", + params![name, email, address], + ).map_err(|e| e.to_string())?; + counts["clients"] = serde_json::json!(counts["clients"].as_i64().unwrap_or(0) + 1); + } + } + + // Import projects + if let Some(projects) = data.get("projects").and_then(|v| v.as_array()) { + for project in projects { + let name = project.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if name.is_empty() { continue; } + let hourly_rate = project.get("hourly_rate").and_then(|v| v.as_f64()).unwrap_or(0.0); + let color = project.get("color").and_then(|v| v.as_str()).unwrap_or("#F59E0B"); + + // Find client_id if client_name is provided + let client_id: Option = project.get("client_name") + .and_then(|v| v.as_str()) + .and_then(|client_name| { + conn.query_row( + "SELECT id FROM clients WHERE name = ?1", + params![client_name], + |row| row.get(0), + ).ok() + }); + + // Check if project already exists + let exists: bool = conn.query_row( + "SELECT COUNT(*) FROM projects WHERE name = ?1", + params![name], + |row| row.get::<_, i64>(0), + ).map_err(|e| e.to_string())? > 0; + + if !exists { + conn.execute( + "INSERT INTO projects (client_id, name, hourly_rate, color, archived) VALUES (?1, ?2, ?3, ?4, 0)", + params![client_id, name, hourly_rate, color], + ).map_err(|e| e.to_string())?; + counts["projects"] = serde_json::json!(counts["projects"].as_i64().unwrap_or(0) + 1); + } + } + } + + // Import tags + if let Some(tags) = data.get("tags").and_then(|v| v.as_array()) { + for tag in tags { + let name = tag.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if name.is_empty() { continue; } + let color = tag.get("color").and_then(|v| v.as_str()).unwrap_or("#6B7280"); + conn.execute( + "INSERT OR IGNORE INTO tags (name, color) VALUES (?1, ?2)", + params![name, color], + ).map_err(|e| e.to_string())?; + counts["tags"] = serde_json::json!(counts["tags"].as_i64().unwrap_or(0) + 1); + } + } + + // Import time entries + if let Some(entries) = data.get("time_entries").and_then(|v| v.as_array()) { + for entry in entries { + let project_name = entry.get("project_name").and_then(|v| v.as_str()).unwrap_or(""); + let start_time = entry.get("start_time").and_then(|v| v.as_str()).unwrap_or(""); + if start_time.is_empty() { continue; } + + let project_id: Option = if !project_name.is_empty() { + conn.query_row( + "SELECT id FROM projects WHERE name = ?1", + params![project_name], + |row| row.get(0), + ).ok() + } else { + entry.get("project_id").and_then(|v| v.as_i64()) + }; + + if let Some(pid) = project_id { + let description = entry.get("description").and_then(|v| v.as_str()); + let end_time = entry.get("end_time").and_then(|v| v.as_str()); + let duration = entry.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); + + conn.execute( + "INSERT INTO time_entries (project_id, description, start_time, end_time, duration) VALUES (?1, ?2, ?3, ?4, ?5)", + params![pid, description, start_time, end_time, duration], + ).map_err(|e| e.to_string())?; + counts["entries"] = serde_json::json!(counts["entries"].as_i64().unwrap_or(0) + 1); + } + } + } + + Ok(counts) +} diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 33a5790..926ef23 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -204,6 +204,17 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { "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')", [])?; Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 44c6f46..a2259b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -77,6 +77,11 @@ pub fn run() { commands::create_favorite, commands::delete_favorite, commands::reorder_favorites, + commands::get_goal_progress, + commands::get_profitability_report, + commands::get_timesheet_data, + commands::import_entries, + commands::import_json_data, ]) .setup(|app| { #[cfg(desktop)]