feat: add goals, profitability, timesheet, and import commands
This commit is contained in:
@@ -735,3 +735,320 @@ pub fn reorder_favorites(state: State<AppState>, ids: Vec<i64>) -> Result<(), St
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Goals command
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_goal_progress(state: State<AppState>, today: String) -> Result<serde_json::Value, String> {
|
||||||
|
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<AppState>, start_date: String, end_date: String) -> Result<Vec<serde_json::Value>, 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<f64> = row.get(4)?;
|
||||||
|
let budget_amount: Option<f64> = 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<String>>(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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timesheet data command
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_timesheet_data(state: State<AppState>, week_start: String) -> Result<Vec<serde_json::Value>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Get the 7 days of the week
|
||||||
|
let mut days: Vec<String> = 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<i64>, Option<String>, 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::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Group by project+task
|
||||||
|
let mut grouped: std::collections::HashMap<(i64, Option<i64>), 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<AppState>, entries: Vec<serde_json::Value>) -> Result<i64, String> {
|
||||||
|
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<AppState>, data: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||||
|
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<i64> = 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<i64> = 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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')",
|
"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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ pub fn run() {
|
|||||||
commands::create_favorite,
|
commands::create_favorite,
|
||||||
commands::delete_favorite,
|
commands::delete_favorite,
|
||||||
commands::reorder_favorites,
|
commands::reorder_favorites,
|
||||||
|
commands::get_goal_progress,
|
||||||
|
commands::get_profitability_report,
|
||||||
|
commands::get_timesheet_data,
|
||||||
|
commands::import_entries,
|
||||||
|
commands::import_json_data,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
|
|||||||
Reference in New Issue
Block a user