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(())
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user