feat: add goals, profitability, timesheet, and import commands

This commit is contained in:
Your Name
2026-02-18 02:04:10 +02:00
parent 1ee4562647
commit f0885921ae
3 changed files with 333 additions and 0 deletions

View File

@@ -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)
}

View File

@@ -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(())
} }

View File

@@ -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)]