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 ef6255042d
commit ee82abe63e
144 changed files with 13351 additions and 3456 deletions

44
src-tauri/Cargo.lock generated
View File

@@ -2100,28 +2100,6 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "local-time-tracker"
version = "1.0.0"
dependencies = [
"chrono",
"env_logger",
"log",
"png",
"rusqlite",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-global-shortcut",
"tauri-plugin-notification",
"tauri-plugin-shell",
"tauri-plugin-window-state",
"windows 0.58.0",
]
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -5763,6 +5741,28 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zeroclock"
version = "1.0.0"
dependencies = [
"chrono",
"env_logger",
"log",
"png",
"rusqlite",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-global-shortcut",
"tauri-plugin-notification",
"tauri-plugin-shell",
"tauri-plugin-window-state",
"windows 0.58.0",
]
[[package]]
name = "zerocopy"
version = "0.8.39"

View File

@@ -1,12 +1,12 @@
[package]
name = "local-time-tracker"
name = "zeroclock"
version = "1.0.0"
description = "A local time tracking app with invoicing"
authors = ["you"]
edition = "2021"
[lib]
name = "local_time_tracker_lib"
name = "zeroclock_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 B

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

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

View File

@@ -19,6 +19,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
"ALTER TABLE clients ADD COLUMN tax_id TEXT",
"ALTER TABLE clients ADD COLUMN payment_terms TEXT",
"ALTER TABLE clients ADD COLUMN notes TEXT",
"ALTER TABLE clients ADD COLUMN currency TEXT",
];
for sql in &migration_columns {
match conn.execute(sql, []) {
@@ -52,6 +53,8 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
"ALTER TABLE projects ADD COLUMN budget_amount REAL DEFAULT NULL",
"ALTER TABLE projects ADD COLUMN rounding_override INTEGER DEFAULT NULL",
"ALTER TABLE projects ADD COLUMN timeline_override TEXT DEFAULT NULL",
"ALTER TABLE projects ADD COLUMN notes TEXT",
"ALTER TABLE projects ADD COLUMN currency TEXT",
];
for sql in &project_migrations {
match conn.execute(sql, []) {
@@ -76,9 +79,10 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
[],
)?;
// Migrate tasks table - add estimated_hours column (safe to re-run)
// Migrate tasks table (safe to re-run)
let task_migrations = [
"ALTER TABLE tasks ADD COLUMN estimated_hours REAL DEFAULT NULL",
"ALTER TABLE tasks ADD COLUMN hourly_rate REAL DEFAULT NULL",
];
for sql in &task_migrations {
match conn.execute(sql, []) {
@@ -302,6 +306,22 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
[],
)?;
// Migrate calendar_events table - add description column (safe to re-run)
let calendar_migrations = [
"ALTER TABLE calendar_events ADD COLUMN description TEXT",
];
for sql in &calendar_migrations {
match conn.execute(sql, []) {
Ok(_) => {}
Err(e) => {
let msg = e.to_string();
if !msg.contains("duplicate column") {
return Err(e);
}
}
}
}
conn.execute(
"CREATE TABLE IF NOT EXISTS timesheet_locks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -345,6 +365,38 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS invoice_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER NOT NULL,
amount REAL NOT NULL,
date TEXT NOT NULL,
method TEXT,
notes TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS recurring_invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
template_id TEXT,
line_items_json TEXT NOT NULL,
tax_rate REAL DEFAULT 0,
discount REAL DEFAULT 0,
notes TEXT,
recurrence_rule TEXT NOT NULL,
next_due_date TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients(id)
)",
[],
)?;
// Insert default settings
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('hourly_rate', '50')",

View File

@@ -6,6 +6,7 @@ use tauri::Manager;
mod database;
mod commands;
mod os_detection;
mod seed;
pub struct AppState {
pub db: Mutex<Connection>,
@@ -135,10 +136,26 @@ pub fn run() {
commands::get_entry_templates,
commands::create_entry_template,
commands::delete_entry_template,
commands::update_entry_template,
commands::get_timesheet_rows,
commands::save_timesheet_rows,
commands::get_previous_week_structure,
commands::auto_backup,
commands::search_entries,
commands::list_backup_files,
commands::delete_backup_file,
commands::get_recent_descriptions,
commands::check_entry_overlap,
commands::get_task_actuals,
commands::get_invoice_payments,
commands::add_invoice_payment,
commands::delete_invoice_payment,
commands::get_recurring_invoices,
commands::create_recurring_invoice,
commands::update_recurring_invoice,
commands::delete_recurring_invoice,
commands::check_recurring_invoices,
commands::seed_sample_data,
])
.setup(|app| {
#[cfg(desktop)]
@@ -151,6 +168,7 @@ pub fn run() {
let menu = Menu::with_items(app, &[&show, &quit])?;
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| {

View File

@@ -4,5 +4,5 @@
)]
fn main() {
local_time_tracker_lib::run();
zeroclock_lib::run();
}

View File

@@ -0,0 +1,348 @@
use serde::Serialize;
use std::collections::HashMap;
use windows::core::{PCWSTR, PWSTR};
use windows::Win32::Foundation::{BOOL, HWND, LPARAM};
use windows::Win32::Graphics::Gdi::{
CreateCompatibleDC, DeleteDC, DeleteObject, GetDIBits, GetObjectW, BITMAP, BITMAPINFO,
BITMAPINFOHEADER, DIB_RGB_COLORS, HBITMAP,
};
use windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES;
use windows::Win32::System::Threading::{
OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT, PROCESS_QUERY_LIMITED_INFORMATION,
};
use windows::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO};
use windows::Win32::UI::Shell::{SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_SMALLICON};
use windows::Win32::UI::WindowsAndMessaging::{
DestroyIcon, EnumWindows, GetIconInfo, GetWindowTextLengthW, GetWindowTextW,
GetWindowThreadProcessId, IsIconic, IsWindowVisible, ICONINFO,
};
#[derive(Debug, Serialize, Clone)]
pub struct WindowInfo {
pub exe_name: String,
pub exe_path: String,
pub title: String,
pub display_name: String,
pub icon: Option<String>,
}
pub fn get_system_idle_seconds() -> u64 {
unsafe {
let mut info = LASTINPUTINFO {
cbSize: std::mem::size_of::<LASTINPUTINFO>() as u32,
dwTime: 0,
};
if GetLastInputInfo(&mut info).as_bool() {
let tick_count = windows::Win32::System::SystemInformation::GetTickCount();
let idle_ms = tick_count.wrapping_sub(info.dwTime);
(idle_ms / 1000) as u64
} else {
0
}
}
}
fn get_process_exe_path(pid: u32) -> Option<String> {
unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
let mut buf = [0u16; 1024];
let mut size = buf.len() as u32;
QueryFullProcessImageNameW(handle, PROCESS_NAME_FORMAT(0), PWSTR(buf.as_mut_ptr()), &mut size).ok()?;
let _ = windows::Win32::Foundation::CloseHandle(handle);
let path = String::from_utf16_lossy(&buf[..size as usize]);
Some(path)
}
}
fn get_window_title(hwnd: HWND) -> String {
unsafe {
let len = GetWindowTextLengthW(hwnd);
if len == 0 {
return String::new();
}
let mut buf = vec![0u16; (len + 1) as usize];
let copied = GetWindowTextW(hwnd, &mut buf);
String::from_utf16_lossy(&buf[..copied as usize])
}
}
fn exe_name_from_path(path: &str) -> String {
path.rsplit('\\').next().unwrap_or(path).to_string()
}
fn display_name_from_exe(exe_name: &str) -> String {
exe_name
.strip_suffix(".exe")
.or_else(|| exe_name.strip_suffix(".EXE"))
.unwrap_or(exe_name)
.to_string()
}
struct EnumState {
windows: Vec<WindowInfo>,
include_minimized: bool,
}
unsafe extern "system" fn enum_windows_callback(hwnd: HWND, lparam: LPARAM) -> BOOL {
let state = &mut *(lparam.0 as *mut EnumState);
if !IsWindowVisible(hwnd).as_bool() {
return BOOL(1);
}
if !state.include_minimized && IsIconic(hwnd).as_bool() {
return BOOL(1);
}
let title = get_window_title(hwnd);
if title.is_empty() {
return BOOL(1);
}
let mut pid: u32 = 0;
GetWindowThreadProcessId(hwnd, Some(&mut pid));
if pid == 0 {
return BOOL(1);
}
if let Some(exe_path) = get_process_exe_path(pid) {
let exe_name = exe_name_from_path(&exe_path);
let display_name = display_name_from_exe(&exe_name);
state.windows.push(WindowInfo {
exe_name,
exe_path,
title,
display_name,
icon: None,
});
}
BOOL(1)
}
pub fn enumerate_visible_windows() -> Vec<WindowInfo> {
let mut state = EnumState {
windows: Vec::new(),
include_minimized: false,
};
unsafe {
let _ = EnumWindows(
Some(enum_windows_callback),
LPARAM(&mut state as *mut EnumState as isize),
);
}
state.windows
}
pub fn enumerate_running_processes() -> Vec<WindowInfo> {
let mut state = EnumState {
windows: Vec::new(),
include_minimized: true,
};
unsafe {
let _ = EnumWindows(
Some(enum_windows_callback),
LPARAM(&mut state as *mut EnumState as isize),
);
}
// Deduplicate by exe_path (case-insensitive)
let mut seen = HashMap::new();
let mut result = Vec::new();
for w in state.windows {
let key = w.exe_path.to_lowercase();
if !seen.contains_key(&key) {
seen.insert(key, true);
result.push(w);
}
}
result.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()));
// Extract icons for the deduplicated list
for w in &mut result {
w.icon = extract_icon_data_url(&w.exe_path);
}
result
}
// --- Icon extraction ---
fn extract_icon_data_url(exe_path: &str) -> Option<String> {
unsafe {
let wide: Vec<u16> = exe_path.encode_utf16().chain(std::iter::once(0)).collect();
let mut fi = SHFILEINFOW::default();
let res = SHGetFileInfoW(
PCWSTR(wide.as_ptr()),
FILE_FLAGS_AND_ATTRIBUTES(0),
Some(&mut fi),
std::mem::size_of::<SHFILEINFOW>() as u32,
SHGFI_ICON | SHGFI_SMALLICON,
);
if res == 0 || fi.hIcon.is_invalid() {
return None;
}
let hicon = fi.hIcon;
let mut ii = ICONINFO::default();
if GetIconInfo(hicon, &mut ii).is_err() {
let _ = DestroyIcon(hicon);
return None;
}
let result = extract_icon_pixels(ii.hbmColor, ii.hbmMask).and_then(|(rgba, w, h)| {
let png_bytes = encode_rgba_to_png(&rgba, w, h)?;
Some(format!("data:image/png;base64,{}", base64_encode(&png_bytes)))
});
// Cleanup
if !ii.hbmColor.is_invalid() {
let _ = DeleteObject(ii.hbmColor);
}
if !ii.hbmMask.is_invalid() {
let _ = DeleteObject(ii.hbmMask);
}
let _ = DestroyIcon(hicon);
result
}
}
unsafe fn extract_icon_pixels(
hbm_color: HBITMAP,
hbm_mask: HBITMAP,
) -> Option<(Vec<u8>, u32, u32)> {
if hbm_color.is_invalid() {
return None;
}
let mut bm = BITMAP::default();
if GetObjectW(
hbm_color,
std::mem::size_of::<BITMAP>() as i32,
Some(&mut bm as *mut BITMAP as *mut std::ffi::c_void),
) == 0
{
return None;
}
let w = bm.bmWidth as u32;
let h = bm.bmHeight as u32;
if w == 0 || h == 0 {
return None;
}
let hdc = CreateCompatibleDC(None);
// Read color bitmap as 32-bit BGRA
let mut bmi = make_bmi(w, h);
let mut bgra = vec![0u8; (w * h * 4) as usize];
let lines = GetDIBits(
hdc,
hbm_color,
0,
h,
Some(bgra.as_mut_ptr() as *mut std::ffi::c_void),
&mut bmi,
DIB_RGB_COLORS,
);
if lines == 0 {
let _ = DeleteDC(hdc);
return None;
}
// Check if any pixel has a non-zero alpha
let has_alpha = bgra.chunks_exact(4).any(|px| px[3] != 0);
if !has_alpha && !hbm_mask.is_invalid() {
// Read the mask bitmap as 32-bit to determine transparency
let mut mask_bmi = make_bmi(w, h);
let mut mask = vec![0u8; (w * h * 4) as usize];
GetDIBits(
hdc,
hbm_mask,
0,
h,
Some(mask.as_mut_ptr() as *mut std::ffi::c_void),
&mut mask_bmi,
DIB_RGB_COLORS,
);
// Mask: black (0,0,0) = opaque, white = transparent
for i in (0..bgra.len()).step_by(4) {
bgra[i + 3] = if mask[i] == 0 && mask[i + 1] == 0 && mask[i + 2] == 0 {
255
} else {
0
};
}
} else if !has_alpha {
// No mask, assume fully opaque
for px in bgra.chunks_exact_mut(4) {
px[3] = 255;
}
}
let _ = DeleteDC(hdc);
// BGRA -> RGBA
for px in bgra.chunks_exact_mut(4) {
px.swap(0, 2);
}
Some((bgra, w, h))
}
fn make_bmi(w: u32, h: u32) -> BITMAPINFO {
BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: w as i32,
biHeight: -(h as i32), // top-down
biPlanes: 1,
biBitCount: 32,
biCompression: 0, // BI_RGB
..Default::default()
},
..Default::default()
}
}
fn encode_rgba_to_png(pixels: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
let mut buf = Vec::new();
{
let mut encoder = png::Encoder::new(&mut buf, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().ok()?;
writer.write_image_data(pixels).ok()?;
writer.finish().ok()?;
}
Some(buf)
}
fn base64_encode(data: &[u8]) -> String {
const CHARS: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
for chunk in data.chunks(3) {
let b = [
chunk[0],
chunk.get(1).copied().unwrap_or(0),
chunk.get(2).copied().unwrap_or(0),
];
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
out.push(CHARS[((n >> 18) & 63) as usize] as char);
out.push(CHARS[((n >> 12) & 63) as usize] as char);
out.push(if chunk.len() > 1 {
CHARS[((n >> 6) & 63) as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
CHARS[(n & 63) as usize] as char
} else {
'='
});
}
out
}

672
src-tauri/src/seed.rs Normal file
View File

@@ -0,0 +1,672 @@
use rusqlite::Connection;
fn hash(n: u32) -> u32 {
let x = n.wrapping_mul(2654435761);
let y = (x ^ (x >> 16)).wrapping_mul(2246822519);
y ^ (y >> 13)
}
fn offset_to_ymd(offset: u32) -> (u32, u32, u32) {
static MONTHS: [(u32, u32, u32); 12] = [
(2025, 3, 31),
(2025, 4, 30),
(2025, 5, 31),
(2025, 6, 30),
(2025, 7, 31),
(2025, 8, 31),
(2025, 9, 30),
(2025, 10, 31),
(2025, 11, 30),
(2025, 12, 31),
(2026, 1, 31),
(2026, 2, 28),
];
let mut rem = offset;
for &(y, m, d) in &MONTHS {
if rem < d {
return (y, m, rem + 1);
}
rem -= d;
}
(2026, 2, 28)
}
struct ProjPeriod {
project_id: i64,
task_ids: &'static [i64],
desc_pool: usize,
start_day: u32,
end_day: u32,
billable: i64,
}
static PROJ_PERIODS: &[ProjPeriod] = &[
ProjPeriod { project_id: 1, task_ids: &[1, 2, 3, 4], desc_pool: 0, start_day: 2, end_day: 75, billable: 1 },
ProjPeriod { project_id: 2, task_ids: &[5, 6, 7, 8], desc_pool: 1, start_day: 2, end_day: 155, billable: 1 },
ProjPeriod { project_id: 3, task_ids: &[9, 10, 11, 12], desc_pool: 1, start_day: 33, end_day: 122, billable: 1 },
ProjPeriod { project_id: 4, task_ids: &[13, 14, 15, 16], desc_pool: 0, start_day: 63, end_day: 183, billable: 1 },
ProjPeriod { project_id: 5, task_ids: &[17, 18], desc_pool: 7, start_day: 2, end_day: 356, billable: 0 },
ProjPeriod { project_id: 6, task_ids: &[19, 20, 21], desc_pool: 3, start_day: 93, end_day: 155, billable: 1 },
ProjPeriod { project_id: 7, task_ids: &[22, 23, 24, 25], desc_pool: 5, start_day: 122, end_day: 183, billable: 1 },
ProjPeriod { project_id: 8, task_ids: &[26, 27, 28, 29], desc_pool: 0, start_day: 155, end_day: 214, billable: 1 },
ProjPeriod { project_id: 9, task_ids: &[30, 31, 32, 33], desc_pool: 4, start_day: 184, end_day: 244, billable: 1 },
ProjPeriod { project_id: 10, task_ids: &[34, 35, 36], desc_pool: 1, start_day: 214, end_day: 244, billable: 1 },
ProjPeriod { project_id: 11, task_ids: &[37, 38, 39, 40], desc_pool: 5, start_day: 214, end_day: 275, billable: 1 },
ProjPeriod { project_id: 12, task_ids: &[41, 42, 43], desc_pool: 3, start_day: 245, end_day: 336, billable: 1 },
ProjPeriod { project_id: 13, task_ids: &[44, 45, 46, 47], desc_pool: 1, start_day: 275, end_day: 356, billable: 1 },
ProjPeriod { project_id: 14, task_ids: &[48, 49, 50], desc_pool: 1, start_day: 306, end_day: 356, billable: 1 },
ProjPeriod { project_id: 15, task_ids: &[51, 52], desc_pool: 0, start_day: 306, end_day: 336, billable: 1 },
ProjPeriod { project_id: 16, task_ids: &[53, 54, 55], desc_pool: 1, start_day: 306, end_day: 356, billable: 1 },
ProjPeriod { project_id: 17, task_ids: &[56, 57, 58], desc_pool: 1, start_day: 93, end_day: 356, billable: 1 },
ProjPeriod { project_id: 18, task_ids: &[59, 60], desc_pool: 7, start_day: 214, end_day: 244, billable: 0 },
];
static DESC_POOLS: &[&[&str]] = &[
// 0: Logo/brand
&[
"Concept sketches - exploring directions",
"Color palette tests on paper",
"Wordmark spacing and kerning",
"Symbol refinement - tightening curves",
"Client presentation deck",
"Applying revision notes from call",
"Final vector cleanup and export",
"Brand guidelines page layout",
"Moodboard assembly",
"Scanning hand-drawn letterforms",
],
// 1: Illustration
&[
"Thumbnail compositions",
"Reference gathering and mood board",
"Rough pencil sketches",
"Inking line art",
"Flat color blocking",
"Rendering pass - light and shadow",
"Background and texture work",
"Final cleanup and detail pass",
"Scanning and color correction",
"Exploring alternate compositions",
"Detail work on foreground elements",
"Adding halftone textures",
],
// 2: Typography/layout
&[
"Typography pairing tests",
"Page layout drafts",
"Grid and margin adjustments",
"Hierarchy and scale refinement",
"Print proof review",
"Spread layout and flow",
],
// 3: Web/digital
&[
"Wireframe sketches on paper",
"Homepage hero illustration",
"Responsive layout mockups",
"Icon set - first batch",
"Custom divider illustrations",
"Gallery page layout",
"Color theme adjustments for screen",
"Asset export for dev handoff",
],
// 4: Packaging
&[
"Die-cut template measurements",
"Repeating pattern tile design",
"Label artwork - front panel",
"Box mockup rendering",
"Press-ready PDF export",
"Color proofing adjustments",
"Tissue paper pattern",
"Sticker sheet layout",
],
// 5: Book cover
&[
"Reading manuscript excerpts for feel",
"Cover thumbnail sketches",
"Main illustration - rough draft",
"Color composition study",
"Title lettering and spine layout",
"Full cover rendering",
"Back cover synopsis layout",
"Author photo placement and bio",
],
// 6: Meeting/admin
&[
"Client call - project kickoff",
"Reviewing feedback document",
"Scope and timeline email",
"Invoice prep and send",
"File organization and archiving",
],
// 7: Personal
&[
"Selecting portfolio pieces",
"Writing case study notes",
"Photographing finished prints",
"Updating website gallery",
"Sketching for fun",
"Ink drawing - daily prompt",
"Scanning and posting work",
"Reorganizing reference library",
],
];
pub fn seed(conn: &Connection) -> Result<(), String> {
let e = |err: rusqlite::Error| err.to_string();
conn.execute_batch(
"PRAGMA foreign_keys = OFF;
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 tags;
DELETE FROM calendar_events;
DELETE FROM calendar_sources;
DELETE FROM sqlite_sequence;
PRAGMA foreign_keys = ON;",
)
.map_err(e)?;
// ==========================================
// CLIENTS
// ==========================================
conn.execute_batch(
"INSERT INTO clients (id, name, email, company, phone, payment_terms, notes) VALUES
(1, 'Anna Kowalski', 'anna@moonlightbakery.com', 'Moonlight Bakery', '555-0142', 'net_30', 'Longtime client. Loves warm earth tones and hand-drawn feel.'),
(2, 'James Okonkwo', 'james@riverandstone.com', 'River & Stone Pottery', '555-0238', 'net_15', 'Prefers email. Needs high-res for print catalog.'),
(3, 'Rosa Delgado', 'rosa@velvetsparrow.com', 'The Velvet Sparrow', '555-0319', 'net_30', 'Band manager. Quick feedback, clear direction.'),
(4, 'Tom Brennan', 'tom@fernandwillow.com', 'Fern & Willow Cafe', '555-0421', 'net_30', 'Very responsive. The cafe on Elm St has great coffee.'),
(5, 'Marcus Chen', 'marcus@marcuschen.com', NULL, '555-0517', 'due_on_receipt', 'Photographer. Good referral source.'),
(6, 'Diane Huang', 'diane@wildfieldpress.com', 'Wildfield Press', '555-0634', 'net_45', 'Publisher - steady ongoing work. Pays reliably.'),
(7, 'Kai Nishimura', 'kai@sableandco.com', 'Sable & Co Tattoo', '555-0728', 'net_15', 'Expects fast turnaround. Loves bold linework.');",
)
.map_err(e)?;
// ==========================================
// PROJECTS
// ==========================================
conn.execute_batch(
"INSERT INTO projects (id, client_id, name, hourly_rate, color, archived, budget_hours, notes) VALUES
(1, 1, 'Moonlight Logo Redesign', 65, '#F59E0B', 1, 50, 'Modernizing the logo. Keep the crescent moon motif.'),
(2, 2, 'Product Catalog', 70, '#8B5CF6', 1, 130, '48-page catalog for spring/summer pottery collection.'),
(3, 3, 'Album Cover - Quiet Hours', 75, '#EF4444', 1, 65, 'Debut album. Dreamy watercolor feel, night sky theme.'),
(4, 4, 'Fern & Willow Rebrand', 70, '#10B981', 1, 110, 'Full rebrand - logo, menu boards, signage, socials.'),
(5, NULL, 'Portfolio Update', 0, '#6B7280', 0, NULL, 'Ongoing portfolio maintenance and case studies.'),
(6, 5, 'Portfolio Website', 60, '#3B82F6', 1, 55, 'Custom illustrations for photography portfolio.'),
(7, 6, 'Tide Pool Dreams - Cover', 75, '#06B6D4', 1, 60, 'Middle-grade novel cover. Lush underwater scene.'),
(8, 7, 'Sable & Co Brand Kit', 80, '#A855F7', 1, 55, 'Full identity - logo, cards, signage, flash sheet.'),
(9, 1, 'Seasonal Packaging', 60, '#EC4899', 1, 70, 'Holiday gift box designs and labels.'),
(10, 3, 'Tour Poster - West Coast', 60, '#DC2626', 1, 35, 'Screenprint poster for 12-city tour.'),
(11, 6, 'Moth & Lantern - Cover', 75, '#0EA5E9', 1, 60, 'YA fantasy novel cover. Moths, lantern light, forest.'),
(12, 2, 'Website Illustrations', 65, '#6366F1', 0, 85, 'Custom spot illustrations for new e-commerce site.'),
(13, 4, 'Mural Design', 65, '#34D399', 0, 75, 'Interior mural - botanical garden theme, 8ft x 12ft.'),
(14, 1, 'Menu Illustrations', 55, '#F97316', 0, 45, 'Hand-drawn food illos for seasonal menu refresh.'),
(15, 5, 'Business Cards', 50, '#60A5FA', 1, 18, 'Custom illustrated business card with foil stamp.'),
(16, 3, 'Merch Designs', 55, '#F43F5E', 0, 40, 'T-shirt, sticker, and tote bag art for online store.'),
(17, 6, 'Monthly Spot Illustrations', 50, '#14B8A6', 0, 100, 'Recurring spot illos for chapter headers in books.'),
(18, NULL, 'Inktober 2025', 0, '#1F2937', 1, NULL, 'Personal daily ink drawing challenge.');",
)
.map_err(e)?;
// ==========================================
// TASKS (60 tasks across 18 projects)
// ==========================================
conn.execute_batch(
"INSERT INTO tasks (id, project_id, name, estimated_hours) VALUES
(1, 1, 'Research', 8),
(2, 1, 'Sketching', 15),
(3, 1, 'Refinement', 15),
(4, 1, 'Final Delivery', 10),
(5, 2, 'Photography Layout', 30),
(6, 2, 'Illustration', 50),
(7, 2, 'Typography', 25),
(8, 2, 'Print Prep', 20),
(9, 3, 'Concept Art', 15),
(10, 3, 'Main Illustration', 25),
(11, 3, 'Lettering', 12),
(12, 3, 'File Prep', 8),
(13, 4, 'Brand Strategy', 15),
(14, 4, 'Logo Design', 35),
(15, 4, 'Collateral', 35),
(16, 4, 'Signage', 20),
(17, 5, 'Curation', NULL),
(18, 5, 'Photography', NULL),
(19, 6, 'Wireframes', 12),
(20, 6, 'Visual Design', 25),
(21, 6, 'Asset Creation', 15),
(22, 7, 'Reading', 8),
(23, 7, 'Sketches', 15),
(24, 7, 'Cover Art', 25),
(25, 7, 'Layout', 10),
(26, 8, 'Research', 10),
(27, 8, 'Concepts', 15),
(28, 8, 'Refinement', 18),
(29, 8, 'Brand Kit', 12),
(30, 9, 'Template Setup', 10),
(31, 9, 'Pattern Design', 20),
(32, 9, 'Label Art', 25),
(33, 9, 'Press Files', 12),
(34, 10, 'Layout', 10),
(35, 10, 'Illustration', 18),
(36, 10, 'Print Prep', 5),
(37, 11, 'Reading', 8),
(38, 11, 'Sketches', 15),
(39, 11, 'Cover Art', 25),
(40, 11, 'Layout', 10),
(41, 12, 'Page Illustrations', 30),
(42, 12, 'Icon Set', 25),
(43, 12, 'Banner Art', 20),
(44, 13, 'Concept', 12),
(45, 13, 'Scale Drawing', 20),
(46, 13, 'Color Studies', 18),
(47, 13, 'Detail Work', 22),
(48, 14, 'Food Illustrations', 20),
(49, 14, 'Layout', 12),
(50, 14, 'Spot Art', 10),
(51, 15, 'Design', 12),
(52, 15, 'Print Prep', 5),
(53, 16, 'T-shirt Art', 15),
(54, 16, 'Sticker Designs', 12),
(55, 16, 'Tote Bag Art', 10),
(56, 17, 'Sketching', 35),
(57, 17, 'Inking', 35),
(58, 17, 'Coloring', 25),
(59, 18, 'Daily Prompts', NULL),
(60, 18, 'Scanning', NULL);",
)
.map_err(e)?;
// ==========================================
// TAGS
// ==========================================
conn.execute_batch(
"INSERT INTO tags (id, name, color) VALUES
(1, 'rush', '#EF4444'),
(2, 'revision', '#F59E0B'),
(3, 'pro-bono', '#10B981'),
(4, 'personal', '#6B7280'),
(5, 'concept', '#8B5CF6'),
(6, 'final', '#3B82F6'),
(7, 'meeting', '#EC4899'),
(8, 'admin', '#6366F1');",
)
.map_err(e)?;
// ==========================================
// TIME ENTRIES (generated)
// ==========================================
let mut stmt = conn
.prepare(
"INSERT INTO time_entries (project_id, task_id, description, start_time, end_time, duration, billable)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)
.map_err(e)?;
let session_starts: [(u32, u32); 4] = [(9, 0), (11, 0), (13, 30), (16, 0)];
let session_maxmins: [u32; 4] = [120, 150, 150, 120];
let mut entry_count: i64 = 0;
for day_offset in 0u32..357 {
let dow = (6 + day_offset) % 7;
if dow == 0 || dow == 6 {
// Weekend: only Inktober gets weekend work
if day_offset >= 214 && day_offset <= 244 {
let h = hash(day_offset);
if h % 3 == 0 {
let (y, m, d) = offset_to_ymd(day_offset);
let date = format!("{:04}-{:02}-{:02}", y, m, d);
let di = (h / 7) as usize % DESC_POOLS[7].len();
let ti = if h % 2 == 0 { 59i64 } else { 60 };
let dur_mins = 30 + (h % 60);
let start = format!("{}T10:{:02}:00", date, h % 45);
let dur_secs = (dur_mins * 60) as i64;
let end_mins = 10 * 60 + (h % 45) + dur_mins;
let end = format!("{}T{:02}:{:02}:00", date, end_mins / 60, end_mins % 60);
stmt.execute(rusqlite::params![18i64, ti, DESC_POOLS[7][di], start, end, dur_secs, 0i64]).map_err(e)?;
entry_count += 1;
}
}
continue;
}
let h = hash(day_offset);
// Skip ~5% of weekdays (sick/vacation)
if h % 20 == 0 {
continue;
}
let (y, m, d) = offset_to_ymd(day_offset);
let date = format!("{:04}-{:02}-{:02}", y, m, d);
// Collect active projects
let active: Vec<&ProjPeriod> = PROJ_PERIODS
.iter()
.filter(|p| day_offset >= p.start_day && day_offset <= p.end_day)
.filter(|p| {
// Personal/portfolio only shows up ~15% of days
if p.project_id == 5 {
return hash(day_offset.wrapping_mul(5)) % 7 == 0;
}
true
})
.collect();
if active.is_empty() {
continue;
}
let n_sessions = 2 + (h % 2) as usize; // 2-3 sessions
let n_sessions = n_sessions.min(active.len().max(2));
for s in 0..n_sessions {
if s >= 4 {
break;
}
let sh = hash(day_offset * 100 + s as u32);
let proj_idx = (sh as usize) % active.len();
let proj = active[proj_idx];
let task_idx = (sh / 3) as usize % proj.task_ids.len();
let task_id = proj.task_ids[task_idx];
let pool = DESC_POOLS[proj.desc_pool];
let desc_idx = (sh / 7) as usize % pool.len();
let desc = pool[desc_idx];
let (base_h, base_m) = session_starts[s];
let max_mins = session_maxmins[s];
let dur_mins = 45 + sh % (max_mins - 44);
let start_offset_mins = (sh / 11) % 20;
let start_h = base_h + (base_m + start_offset_mins) / 60;
let start_m = (base_m + start_offset_mins) % 60;
let end_total = start_h * 60 + start_m + dur_mins;
let end_h = end_total / 60;
let end_m = end_total % 60;
if end_h >= 19 {
continue;
}
let start = format!("{}T{:02}:{:02}:00", date, start_h, start_m);
let end = format!("{}T{:02}:{:02}:00", date, end_h, end_m);
let dur_secs = (dur_mins * 60) as i64;
stmt.execute(rusqlite::params![
proj.project_id,
task_id,
desc,
start,
end,
dur_secs,
proj.billable
])
.map_err(e)?;
entry_count += 1;
}
// Occasional admin/meeting entry (~20% of days)
if h % 5 == 0 && !active.is_empty() {
let sh = hash(day_offset * 200);
let proj = active[0];
let admin_descs = DESC_POOLS[6];
let di = (sh / 3) as usize % admin_descs.len();
let dur_mins = 15 + sh % 30;
let start = format!("{}T08:{:02}:00", date, 30 + sh % 25);
let end_total_mins = 8 * 60 + 30 + (sh % 25) + dur_mins;
let end = format!(
"{}T{:02}:{:02}:00",
date,
end_total_mins / 60,
end_total_mins % 60
);
stmt.execute(rusqlite::params![
proj.project_id,
proj.task_ids[0],
admin_descs[di],
start,
end,
(dur_mins * 60) as i64,
proj.billable
])
.map_err(e)?;
entry_count += 1;
}
}
drop(stmt);
// ==========================================
// ENTRY TAGS (tag ~15% of entries)
// ==========================================
let total_entries = entry_count;
let tag_assignments: Vec<(i64, i64)> = (1..=total_entries)
.filter_map(|id| {
let h = hash(id as u32 * 31);
if h % 7 != 0 {
return None;
}
let tag = match h % 40 {
0..=5 => 1, // rush
6..=15 => 2, // revision
16..=20 => 5, // concept
21..=28 => 6, // final
29..=33 => 7, // meeting
34..=37 => 8, // admin
_ => 2, // revision
};
Some((id, tag))
})
.collect();
let mut tag_stmt = conn
.prepare("INSERT OR IGNORE INTO entry_tags (entry_id, tag_id) VALUES (?1, ?2)")
.map_err(e)?;
for (eid, tid) in &tag_assignments {
tag_stmt.execute(rusqlite::params![eid, tid]).map_err(e)?;
}
drop(tag_stmt);
// ==========================================
// EXPENSES
// ==========================================
conn.execute_batch(
"INSERT INTO expenses (project_id, client_id, category, description, amount, date, invoiced) VALUES
-- Software subscriptions (monthly)
(5, NULL, 'software', 'Clip Studio Paint Pro - annual', 49.99, '2025-03-15', 0),
(5, NULL, 'software', 'Affinity Designer 2 license', 69.99, '2025-04-02', 0),
(5, NULL, 'software', 'Dropbox Plus - annual renewal', 119.88, '2025-06-01', 0),
(5, NULL, 'software', 'Squarespace portfolio site - annual', 192.00, '2025-07-15', 0),
(17, 6, 'software', 'Font license - Recoleta family', 45.00, '2025-08-20', 1),
-- Art supplies
(1, 1, 'supplies', 'Copic markers (12 pack, warm grays)', 89.99, '2025-03-08', 0),
(3, 3, 'supplies', 'Winsor & Newton watercolor set', 124.50, '2025-04-10', 0),
(2, 2, 'supplies', 'A3 hot press watercolor paper (50 sheets)', 42.00, '2025-05-05', 0),
(4, 4, 'supplies', 'Posca paint markers (8 pack)', 34.99, '2025-06-18', 0),
(18, NULL,'supplies', 'India ink - Sumi (3 bottles)', 27.50, '2025-10-01', 0),
(18, NULL,'supplies', 'Micron pen set (8 widths)', 22.99, '2025-10-03', 0),
(13, 4, 'supplies', 'Acrylic paint (mural - bulk order)', 187.00, '2025-12-20', 0),
(14, 1, 'supplies', 'Brush pen set for menu illos', 18.50, '2026-01-12', 0),
-- Printing
(2, 2, 'printing', 'Test prints - catalog spreads', 85.00, '2025-07-22', 1),
(9, 1, 'printing', 'Packaging prototypes (6 units)', 120.00, '2025-10-15', 1),
(10, 3, 'printing', 'Poster screenprint run (50 copies)', 275.00, '2025-11-01', 1),
(15, 5, 'printing', 'Business card print run (250)', 65.00, '2026-01-28', 1),
-- Reference materials
(5, NULL, 'other', 'Illustration annual 2025', 38.00, '2025-04-22', 0),
(5, NULL, 'other', 'Color and Light by James Gurney', 28.50, '2025-05-30', 0),
(7, 6, 'other', 'Marine biology reference photos (stock)', 29.00, '2025-07-10', 1),
-- Travel
(4, 4, 'travel', 'Bus pass - client site visits (monthly)', 45.00, '2025-06-01', 0),
(4, 4, 'travel', 'Bus pass - client site visits (monthly)', 45.00, '2025-07-01', 0),
(8, 7, 'travel', 'Transit to tattoo parlor for measurements', 8.50, '2025-08-12', 0),
(13, 4, 'travel', 'Transit to cafe for mural measurements', 8.50, '2025-12-15', 0),
(13, 4, 'travel', 'Transit to cafe - mural install day', 8.50, '2026-02-10', 0),
-- Equipment
(5, NULL, 'equipment', 'Tablet screen protector replacement', 24.99, '2025-09-05', 0),
(5, NULL, 'equipment', 'Desk lamp (daylight bulb)', 45.00, '2025-11-20', 0);",
)
.map_err(e)?;
// ==========================================
// INVOICES
// ==========================================
conn.execute_batch(
"INSERT INTO invoices (id, client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status) VALUES
(1, 1, 'INV-2025-001', '2025-05-28', '2025-06-27', 3120.00, 0, 0, 0, 3120.00, 'Logo redesign - concept through final delivery', 'paid'),
(2, 2, 'INV-2025-002', '2025-06-15', '2025-06-30', 4550.00, 0, 0, 0, 4550.00, 'Product catalog - first milestone (layout and illustrations)', 'paid'),
(3, 3, 'INV-2025-003', '2025-06-30', '2025-07-30', 4500.00, 0, 0, 0, 4500.00, 'Album cover art - Quiet Hours', 'paid'),
(4, 4, 'INV-2025-004', '2025-07-31', '2025-08-30', 3850.00, 0, 0, 0, 3850.00, 'Rebrand milestone 1 - logo and primary collateral', 'paid'),
(5, 2, 'INV-2025-005', '2025-08-15', '2025-08-30', 3850.00, 0, 0, 0, 3850.00, 'Product catalog - final milestone (print prep)', 'paid'),
(6, 5, 'INV-2025-006', '2025-08-20', '2025-08-20', 3000.00, 0, 0, 0, 3000.00, 'Portfolio website illustrations', 'paid'),
(7, 6, 'INV-2025-007', '2025-09-10', '2025-10-25', 4125.00, 0, 0, 0, 4125.00, 'Tide Pool Dreams - cover art', 'paid'),
(8, 4, 'INV-2025-008', '2025-09-30', '2025-10-30', 3500.00, 0, 0, 0, 3500.00, 'Rebrand milestone 2 - signage and social templates', 'paid'),
(9, 7, 'INV-2025-009', '2025-10-20', '2025-11-04', 4160.00, 0, 0, 0, 4160.00, 'Sable & Co - full brand kit', 'paid'),
(10, 6, 'INV-2025-010', '2025-09-30', '2025-11-14', 2250.00, 0, 0, 0, 2250.00, 'Monthly spot illustrations - Q3 (Jul-Sep)', 'overdue'),
(11, 1, 'INV-2025-011', '2025-11-25', '2025-12-25', 3780.00, 0, 0, 0, 3780.00, 'Seasonal packaging - holiday gift line', 'paid'),
(12, 3, 'INV-2025-012', '2025-11-20', '2025-12-20', 1860.00, 0, 0, 0, 1860.00, 'Tour poster - West Coast (design + print mgmt)', 'paid'),
(13, 6, 'INV-2025-013', '2025-12-20', '2026-02-03', 4275.00, 0, 0, 0, 4275.00, 'Moth & Lantern - cover art', 'sent'),
(14, 6, 'INV-2025-014', '2025-12-31', '2026-02-14', 2500.00, 0, 0, 0, 2500.00, 'Monthly spot illustrations - Q4 (Oct-Dec)', 'sent'),
(15, 2, 'INV-2026-001', '2026-01-31', '2026-02-14', 4225.00, 0, 0, 0, 4225.00, 'Website illustrations - first half', 'sent'),
(16, 4, 'INV-2026-002', '2026-02-15', '2026-03-17', 2600.00, 0, 0, 0, 2600.00, 'Mural design - concept and scale drawing', 'draft'),
(17, 1, 'INV-2026-003', '2026-02-20', '2026-03-22', 1100.00, 0, 0, 0, 1100.00, 'Menu illustrations - in progress', 'draft'),
(18, 5, 'INV-2026-004', '2026-01-25', '2026-01-25', 750.00, 0, 0, 0, 750.00, 'Business card design and print coordination', 'paid');",
)
.map_err(e)?;
// ==========================================
// INVOICE ITEMS
// ==========================================
conn.execute_batch(
"INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount) VALUES
(1, 'Logo redesign - research and concepts', 12, 65, 780),
(1, 'Logo refinement and final artwork', 24, 65, 1560),
(1, 'Brand guidelines document', 12, 65, 780),
(2, 'Catalog layout - 24 spreads', 30, 70, 2100),
(2, 'Product illustrations', 35, 70, 2450),
(3, 'Album cover art - concept through final', 48, 75, 3600),
(3, 'File preparation and print variants', 12, 75, 900),
(4, 'Brand strategy and logo design', 35, 70, 2450),
(4, 'Collateral design (menu, cards, social)', 20, 70, 1400),
(5, 'Catalog - typography and print preparation', 25, 70, 1750),
(5, 'Final revisions and press files', 30, 70, 2100),
(6, 'Website illustrations and icons', 50, 60, 3000),
(7, 'Cover illustration - concept to final', 45, 75, 3375),
(7, 'Layout, spine, and back cover', 10, 75, 750),
(8, 'Signage designs (3 pieces)', 25, 70, 1750),
(8, 'Social media template set', 25, 70, 1750),
(9, 'Logo and brand identity development', 32, 80, 2560),
(9, 'Brand kit - cards, signage, flash style', 20, 80, 1600),
(10, 'Spot illustrations - July', 15, 50, 750),
(10, 'Spot illustrations - August', 15, 50, 750),
(10, 'Spot illustrations - September', 15, 50, 750),
(11, 'Packaging design - 4 box sizes', 40, 60, 2400),
(11, 'Label art and tissue paper pattern', 23, 60, 1380),
(12, 'Poster illustration and layout', 24, 60, 1440),
(12, 'Print management and color proofing', 7, 60, 420),
(13, 'Cover illustration - Moth & Lantern', 45, 75, 3375),
(13, 'Layout and final files', 12, 75, 900),
(14, 'Spot illustrations - October', 18, 50, 900),
(14, 'Spot illustrations - November', 16, 50, 800),
(14, 'Spot illustrations - December', 16, 50, 800),
(15, 'Page illustrations - 12 pieces', 40, 65, 2600),
(15, 'Icon set - first batch (20 icons)', 25, 65, 1625),
(16, 'Mural concept sketches', 15, 65, 975),
(16, 'Scale drawing and color studies', 25, 65, 1625),
(17, 'Food illustrations (8 of 15)', 20, 55, 1100),
(18, 'Business card design - illustration + layout', 12, 50, 600),
(18, 'Print coordination', 3, 50, 150);",
)
.map_err(e)?;
// ==========================================
// INVOICE PAYMENTS
// ==========================================
conn.execute_batch(
"INSERT INTO invoice_payments (invoice_id, amount, date, method, notes) VALUES
(1, 3120.00, '2025-06-20', 'bank_transfer', 'Paid in full'),
(2, 4550.00, '2025-06-28', 'bank_transfer', NULL),
(3, 4500.00, '2025-07-25', 'bank_transfer', NULL),
(4, 3850.00, '2025-08-28', 'bank_transfer', NULL),
(5, 3850.00, '2025-08-29', 'bank_transfer', NULL),
(6, 3000.00, '2025-08-20', 'bank_transfer', 'Paid same day'),
(7, 4125.00, '2025-10-22', 'bank_transfer', NULL),
(8, 3500.00, '2025-10-28', 'bank_transfer', NULL),
(9, 4160.00, '2025-11-02', 'bank_transfer', 'Paid early'),
(11, 3780.00, '2025-12-18', 'bank_transfer', NULL),
(12, 1860.00, '2025-12-15', 'bank_transfer', NULL),
(18, 750.00, '2026-01-25', 'bank_transfer', 'Paid on receipt');",
)
.map_err(e)?;
// ==========================================
// FAVORITES
// ==========================================
conn.execute_batch(
"INSERT INTO favorites (project_id, task_id, description, sort_order) VALUES
(13, 44, 'Mural concept work', 0),
(14, 48, 'Menu food illustrations', 1),
(17, 56, 'Monthly spot illo', 2),
(16, 53, 'Merch design session', 3);",
)
.map_err(e)?;
// ==========================================
// ENTRY TEMPLATES
// ==========================================
conn.execute_batch(
"INSERT INTO entry_templates (name, project_id, task_id, description, duration, billable) VALUES
('Quick sketch session', 13, 44, 'Concept sketching', 5400, 1),
('Spot illustration', 17, 57, 'Inking spot illustration', 7200, 1),
('Portfolio photo session', 5, 18, 'Photographing prints', 3600, 0),
('Menu illo', 14, 48, 'Food illustration', 5400, 1);",
)
.map_err(e)?;
// ==========================================
// TRACKED APPS
// ==========================================
conn.execute_batch(
"INSERT INTO tracked_apps (project_id, exe_name, display_name) VALUES
(13, 'clip_studio_paint.exe', 'Clip Studio Paint'),
(14, 'clip_studio_paint.exe', 'Clip Studio Paint'),
(12, 'affinity_designer.exe', 'Affinity Designer'),
(16, 'affinity_designer.exe', 'Affinity Designer');",
)
.map_err(e)?;
// ==========================================
// BUSINESS IDENTITY (for invoice previews)
// ==========================================
conn.execute_batch(
"INSERT OR REPLACE INTO settings (key, value) VALUES
('business_name', 'Mika Sato Illustration'),
('business_address', '47 Brush & Ink Lane\nPortland, OR 97205'),
('business_email', 'hello@mikasato.art'),
('business_phone', '(503) 555-0147'),
('hourly_rate', '95');",
)
.map_err(e)?;
Ok(())
}

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "LocalTimeTracker",
"productName": "ZeroClock",
"version": "1.0.0",
"identifier": "com.localtimetracker.app",
"build": {
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "LocalTimeTracker",
"title": "ZeroClock",
"width": 1200,
"height": 800,
"minWidth": 800,
@@ -20,12 +20,21 @@
"decorations": false,
"transparent": false,
"resizable": true
},
{
"label": "mini-timer",
"url": "mini-timer.html",
"title": "Timer",
"width": 300,
"height": 80,
"decorations": false,
"transparent": false,
"resizable": false,
"alwaysOnTop": true,
"skipTaskbar": true,
"visible": false
}
],
"trayIcon": {
"iconPath": "icons/icon.png",
"iconAsTemplate": true
},
"security": {
"csp": null
}