Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Rust commands live in `src-tauri/src/commands.rs`. All commands return `Result<T
- `settings:changed` — full `Settings` object
- `themes:changed` — `Theme[]`
- `sessions:cleared`
- `categories:changed` — `Category[]` (after any category is added, renamed, recolored or deleted)

### TypeScript ↔ Rust type contract

Expand Down
133 changes: 125 additions & 8 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use std::sync::Arc;

use crate::audio::{self, AudioManager};
use crate::notifications;
use crate::db::queries::{Category, StatsFilter};
use crate::db::{queries, DbState};
use crate::settings::{self, Settings};
use crate::shortcuts;
Expand Down Expand Up @@ -336,18 +337,25 @@ pub fn sessions_clear(db: State<'_, DbState>, app: AppHandle) -> Result<(), Stri
// ---------------------------------------------------------------------------

/// Batched stats for Today + This Week tabs (minimises IPC round-trips).
///
/// `filter` hides categories from the results; `None` means no filtering
/// (the categories feature is off).
#[tauri::command]
pub fn stats_get_detailed(db: State<'_, DbState>) -> Result<DetailedStats, String> {
pub fn stats_get_detailed(
filter: Option<StatsFilter>,
db: State<'_, DbState>,
) -> Result<DetailedStats, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
let today = queries::get_daily_stats(&conn).map_err(|e| {
let filter = filter.as_ref();
let today = queries::get_daily_stats(&conn, filter).map_err(|e| {
log::error!("[stats] failed to query daily stats: {e}");
e.to_string()
})?;
let week = queries::get_weekly_stats(&conn).map_err(|e| {
let week = queries::get_weekly_stats(&conn, filter).map_err(|e| {
log::error!("[stats] failed to query weekly stats: {e}");
e.to_string()
})?;
let streak = queries::get_streak(&conn).map_err(|e| {
let streak = queries::get_streak(&conn, filter).map_err(|e| {
log::error!("[stats] failed to query streak: {e}");
e.to_string()
})?;
Expand All @@ -356,17 +364,21 @@ pub fn stats_get_detailed(db: State<'_, DbState>) -> Result<DetailedStats, Strin

/// Heatmap data + lifetime totals for the All Time tab.
#[tauri::command]
pub fn stats_get_heatmap(db: State<'_, DbState>) -> Result<HeatmapStats, String> {
pub fn stats_get_heatmap(
filter: Option<StatsFilter>,
db: State<'_, DbState>,
) -> Result<HeatmapStats, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
let entries = queries::get_heatmap_data(&conn).map_err(|e| {
let filter = filter.as_ref();
let entries = queries::get_heatmap_data(&conn, filter).map_err(|e| {
log::error!("[stats] failed to query heatmap data: {e}");
e.to_string()
})?;
let raw = queries::get_all_time_stats(&conn).map_err(|e| {
let raw = queries::get_all_time_stats(&conn, filter).map_err(|e| {
log::error!("[stats] failed to query all-time stats: {e}");
e.to_string()
})?;
let streak = queries::get_streak(&conn).map_err(|e| {
let streak = queries::get_streak(&conn, filter).map_err(|e| {
log::error!("[stats] failed to query streak for heatmap: {e}");
e.to_string()
})?;
Expand All @@ -378,6 +390,111 @@ pub fn stats_get_heatmap(db: State<'_, DbState>) -> Result<HeatmapStats, String>
})
}

/// Number of completed focus rounds without a category. The stats window
/// only offers an "Uncategorized" filter when this is non-zero.
#[tauri::command]
pub fn stats_uncategorized_count(db: State<'_, DbState>) -> Result<i64, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
queries::count_uncategorized_rounds(&conn).map_err(|e| e.to_string())
}

// ---------------------------------------------------------------------------
// CMD-12 — Category commands
// ---------------------------------------------------------------------------
//
// Every mutation returns the full, updated list and emits `categories:changed`
// with it so the timer, settings and stats windows stay in sync.

/// List all categories in display order.
#[tauri::command]
pub fn categories_list(db: State<'_, DbState>) -> Result<Vec<Category>, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
queries::list_categories(&conn).map_err(|e| e.to_string())
}

/// Create a category at the end of the list.
#[tauri::command]
pub fn categories_create(
name: String,
color: String,
db: State<'_, DbState>,
app: AppHandle,
) -> Result<Vec<Category>, String> {
let name = queries::validate_category_name(&name)?;
queries::validate_category_color(&color)?;
let categories = {
let conn = db.lock().map_err(|e| e.to_string())?;
if queries::count_categories(&conn).map_err(|e| e.to_string())? >= queries::MAX_CATEGORIES {
return Err(format!("at most {} categories are allowed", queries::MAX_CATEGORIES));
}
queries::create_category(&conn, &name, &color).map_err(|e| {
log::error!("[categories] failed to create category: {e}");
e.to_string()
})?;
queries::list_categories(&conn).map_err(|e| e.to_string())?
};
log::info!("[categories] created '{name}'");
app.emit("categories:changed", &categories).ok();
Ok(categories)
}

/// Rename and recolor a category. For a built-in category, an empty or
/// missing `name` restores its localized default name; user-created
/// categories always need a name.
#[tauri::command]
pub fn categories_update(
id: i64,
name: Option<String>,
color: String,
db: State<'_, DbState>,
app: AppHandle,
) -> Result<Vec<Category>, String> {
queries::validate_category_color(&color)?;
let categories = {
let conn = db.lock().map_err(|e| e.to_string())?;
let builtin = queries::is_builtin_category(&conn, id).map_err(|e| e.to_string())?;
let name = match name.as_deref().map(str::trim) {
Some(n) if !n.is_empty() => Some(queries::validate_category_name(n)?),
_ if builtin => None,
_ => return Err("category name must not be empty".to_string()),
};
queries::update_category(&conn, id, name.as_deref(), &color).map_err(|e| {
log::error!("[categories] failed to update category {id}: {e}");
e.to_string()
})?;
queries::list_categories(&conn).map_err(|e| e.to_string())?
};
app.emit("categories:changed", &categories).ok();
Ok(categories)
}

/// Delete a category. Its rounds move to uncategorized; nothing is lost.
#[tauri::command]
pub fn categories_delete(
id: i64,
db: State<'_, DbState>,
app: AppHandle,
) -> Result<Vec<Category>, String> {
let categories = {
let conn = db.lock().map_err(|e| e.to_string())?;
let moved = queries::delete_category(&conn, id).map_err(|e| {
log::error!("[categories] failed to delete category {id}: {e}");
e.to_string()
})?;
log::info!("[categories] deleted category {id}, {moved} sessions now uncategorized");
queries::list_categories(&conn).map_err(|e| e.to_string())?
};
app.emit("categories:changed", &categories).ok();
Ok(categories)
}

/// Completed focus rounds in a category — shown when confirming a delete.
#[tauri::command]
pub fn categories_round_count(id: i64, db: State<'_, DbState>) -> Result<i64, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
queries::count_category_rounds(&conn, id).map_err(|e| e.to_string())
}

// ---------------------------------------------------------------------------
// CMD-05 — Window commands
// ---------------------------------------------------------------------------
Expand Down
79 changes: 77 additions & 2 deletions src-tauri/src/db/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,36 @@ const MIGRATION_6: &str = "
INSERT INTO schema_version VALUES (6);
";

/// Adds session categories: a `categories` table seeded with three built-in
/// categories (work, study, leisure) and a nullable `sessions.category_id`.
/// Existing sessions stay uncategorized (NULL). Built-in categories keep
/// `name` NULL until the user renames them, so the frontend can show a
/// localized name for `builtin_key` instead. The feature is opt-in and
/// seeded as disabled.
const MIGRATION_7: &str = "
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
builtin_key TEXT UNIQUE,
color TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0
);

INSERT OR IGNORE INTO categories (name, builtin_key, color, position) VALUES
(NULL, 'work', '#4A9FF5', 0),
(NULL, 'study', '#F5A623', 1),
(NULL, 'leisure', '#B57EDC', 2);

ALTER TABLE sessions ADD COLUMN category_id INTEGER
REFERENCES categories(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_category_id ON sessions(category_id);

INSERT OR IGNORE INTO settings (key, value) VALUES ('categories_enabled', 'false');
INSERT OR IGNORE INTO settings (key, value) VALUES ('active_category_id', '0');
INSERT OR IGNORE INTO settings (key, value) VALUES ('stats_hidden_categories', '[]');
INSERT INTO schema_version VALUES (7);
";

/// Apply any pending migrations. Each migration is wrapped in a transaction
/// so a partial failure leaves the database unchanged.
pub fn run(conn: &Connection) -> Result<()> {
Expand Down Expand Up @@ -131,6 +161,12 @@ pub fn run(conn: &Connection) -> Result<()> {
log::info!("[db/migrations] MIGRATION_6 complete");
}

if version < 7 {
log::info!("[db/migrations] applying MIGRATION_7: session categories");
conn.execute_batch(&format!("BEGIN; {MIGRATION_7} COMMIT;"))?;
log::info!("[db/migrations] MIGRATION_7 complete");
}

Ok(())
}

Expand Down Expand Up @@ -166,14 +202,14 @@ mod tests {
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 6);
assert_eq!(v, 7);
}

#[test]
fn all_tables_created() {
let conn = Connection::open_in_memory().unwrap();
run(&conn).unwrap();
for table in &["settings", "sessions", "custom_themes", "schema_version"] {
for table in &["settings", "sessions", "custom_themes", "schema_version", "categories"] {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
Expand All @@ -184,4 +220,43 @@ mod tests {
assert_eq!(count, 1, "table '{table}' was not created");
}
}

#[test]
fn migration_7_keeps_existing_sessions_uncategorized() {
// Simulate an upgrade: a v6 database that already holds session history.
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(&format!(
"BEGIN; {MIGRATION_1} {MIGRATION_2} {MIGRATION_3} {MIGRATION_4} {MIGRATION_5} {MIGRATION_6} COMMIT;"
))
.unwrap();
conn.execute(
"INSERT INTO sessions (started_at, round_type, duration_secs, completed)
VALUES (1000, 'work', 1500, 1)",
[],
)
.unwrap();

// Only MIGRATION_7 should fire.
run(&conn).unwrap();

let category: Option<i64> =
conn.query_row("SELECT category_id FROM sessions", [], |r| r.get(0)).unwrap();
assert_eq!(category, None, "existing sessions must stay uncategorized");

let builtins: Vec<String> = conn
.prepare("SELECT builtin_key FROM categories ORDER BY position")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(builtins, ["work", "study", "leisure"]);

let enabled: String = conn
.query_row("SELECT value FROM settings WHERE key = 'categories_enabled'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(enabled, "false", "categories must be opt-in");
}
}
Loading