diff --git a/CLAUDE.md b/CLAUDE.md index 719b5fa6..320e1e1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ Rust commands live in `src-tauri/src/commands.rs`. All commands return `Result, 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 { +pub fn stats_get_detailed( + filter: Option, + db: State<'_, DbState>, +) -> Result { 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() })?; @@ -356,17 +364,21 @@ pub fn stats_get_detailed(db: State<'_, DbState>) -> Result) -> Result { +pub fn stats_get_heatmap( + filter: Option, + db: State<'_, DbState>, +) -> Result { 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() })?; @@ -378,6 +390,111 @@ pub fn stats_get_heatmap(db: State<'_, DbState>) -> Result }) } +/// 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 { + 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, 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, 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, + color: String, + db: State<'_, DbState>, + app: AppHandle, +) -> Result, 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, 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 { + 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 // --------------------------------------------------------------------------- diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index 113c3ac0..36f5af13 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -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<()> { @@ -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(()) } @@ -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", @@ -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 = + 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 = 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"); + } } diff --git a/src-tauri/src/db/queries.rs b/src-tauri/src/db/queries.rs index b707b591..55559461 100644 --- a/src-tauri/src/db/queries.rs +++ b/src-tauri/src/db/queries.rs @@ -1,5 +1,5 @@ -use rusqlite::{params, Connection, Result}; -use serde::Serialize; +use rusqlite::{named_params, params, Connection, OptionalExtension, Result}; +use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- // Session CRUD (DATA-03) @@ -11,32 +11,247 @@ pub fn insert_session( conn: &Connection, round_type: &str, duration_secs: u32, + category_id: Option, ) -> Result { let started_at = unix_now(); conn.execute( - "INSERT INTO sessions (started_at, round_type, duration_secs, completed) - VALUES (?1, ?2, ?3, 0)", - params![started_at, round_type, duration_secs], + "INSERT INTO sessions (started_at, round_type, duration_secs, completed, category_id) + VALUES (?1, ?2, ?3, 0, ?4)", + params![started_at, round_type, duration_secs, category_id], )?; let id = conn.last_insert_rowid(); - log::debug!("[db] session started: id={id} type={round_type} duration={duration_secs}s"); + log::debug!( + "[db] session started: id={id} type={round_type} duration={duration_secs}s category={category_id:?}" + ); Ok(id) } /// Updates a session when the round ends (by completion or skip). +/// +/// `category_id` is the category active when the round ended; `None` leaves +/// the session uncategorized (breaks, or the categories feature is off). pub fn complete_session( conn: &Connection, session_id: i64, completed: bool, + category_id: Option, ) -> Result<()> { conn.execute( - "UPDATE sessions SET ended_at = ?1, completed = ?2 WHERE id = ?3", - params![unix_now(), completed as i64, session_id], + "UPDATE sessions SET ended_at = ?1, completed = ?2, category_id = ?3 WHERE id = ?4", + params![unix_now(), completed as i64, category_id, session_id], )?; - log::debug!("[db] session ended: id={session_id} completed={completed}"); + log::debug!( + "[db] session ended: id={session_id} completed={completed} category={category_id:?}" + ); Ok(()) } +// --------------------------------------------------------------------------- +// Categories +// --------------------------------------------------------------------------- + +/// Maximum number of categories that can exist at once. +pub const MAX_CATEGORIES: i64 = 10; + +/// Maximum length of a category name, in characters. +pub const MAX_CATEGORY_NAME_CHARS: usize = 20; + +#[derive(Debug, Clone, Serialize)] +pub struct Category { + pub id: i64, + /// User-provided name. `None` for a built-in category that has not been + /// renamed — the frontend shows a localized name for `builtin_key` instead. + pub name: Option, + /// "work" | "study" | "leisure" for the built-in categories, `None` otherwise. + pub builtin_key: Option, + /// Hex color, e.g. "#4A9FF5". + pub color: String, + pub position: i64, +} + +pub fn list_categories(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, name, builtin_key, color, position FROM categories ORDER BY position, id", + )?; + let rows = stmt + .query_map([], |r| { + Ok(Category { + id: r.get(0)?, + name: r.get(1)?, + builtin_key: r.get(2)?, + color: r.get(3)?, + position: r.get(4)?, + }) + })? + .collect(); + rows +} + +pub fn count_categories(conn: &Connection) -> Result { + conn.query_row("SELECT COUNT(*) FROM categories", [], |r| r.get(0)) +} + +/// Appends a user-created category at the end of the list and returns its id. +pub fn create_category(conn: &Connection, name: &str, color: &str) -> Result { + conn.execute( + "INSERT INTO categories (name, color, position) + VALUES (?1, ?2, (SELECT COALESCE(MAX(position), -1) + 1 FROM categories))", + params![name, color], + )?; + let id = conn.last_insert_rowid(); + log::debug!("[db] category created: id={id}"); + Ok(id) +} + +/// Renames and recolors a category. A `None` name on a built-in category +/// restores its localized default name. +pub fn update_category(conn: &Connection, id: i64, name: Option<&str>, color: &str) -> Result<()> { + conn.execute( + "UPDATE categories SET name = ?1, color = ?2 WHERE id = ?3", + params![name, color, id], + )?; + log::debug!("[db] category updated: id={id}"); + Ok(()) +} + +/// Deletes a category. Its sessions become uncategorized rather than being +/// deleted, so no history is lost. Returns the number of sessions moved. +pub fn delete_category(conn: &Connection, id: i64) -> Result { + let tx = conn.unchecked_transaction()?; + let moved = + tx.execute("UPDATE sessions SET category_id = NULL WHERE category_id = ?1", [id])?; + tx.execute("DELETE FROM categories WHERE id = ?1", [id])?; + tx.commit()?; + log::debug!("[db] category deleted: id={id} sessions_moved={moved}"); + Ok(moved) +} + +/// Returns true for the built-in categories (work, study, leisure). +pub fn is_builtin_category(conn: &Connection, id: i64) -> Result { + conn.query_row("SELECT builtin_key IS NOT NULL FROM categories WHERE id = ?1", [id], |r| { + r.get(0) + }) +} + +/// Completed focus rounds recorded under a category. +pub fn count_category_rounds(conn: &Connection, id: i64) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM sessions + WHERE round_type = 'work' AND completed = 1 AND category_id = ?1", + [id], + |r| r.get(0), + ) +} + +/// Completed focus rounds that have no category. +pub fn count_uncategorized_rounds(conn: &Connection) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM sessions + WHERE round_type = 'work' AND completed = 1 AND category_id IS NULL", + [], + |r| r.get(0), + ) +} + +/// Resolves the category a finished focus round belongs to: the stored +/// selection if it still exists, otherwise the first category. `None` when +/// every category has been deleted. +pub fn resolve_active_category(conn: &Connection, stored_id: i64) -> Result> { + let stored: Option = conn + .query_row("SELECT id FROM categories WHERE id = ?1", [stored_id], |r| r.get(0)) + .optional()?; + if stored.is_some() { + return Ok(stored); + } + conn.query_row("SELECT id FROM categories ORDER BY position, id LIMIT 1", [], |r| r.get(0)) + .optional() +} + +/// The category a round of `round_type` is recorded under: the active +/// category for focus rounds while categories are enabled, otherwise none. +pub fn round_category( + conn: &Connection, + categories_enabled: bool, + active_category_id: i64, + round_type: &str, +) -> Result> { + if !categories_enabled || round_type != "work" { + return Ok(None); + } + resolve_active_category(conn, active_category_id) +} + +/// Trims a category name and checks it is non-empty and short enough. +pub fn validate_category_name(name: &str) -> std::result::Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("category name must not be empty".to_string()); + } + if trimmed.chars().count() > MAX_CATEGORY_NAME_CHARS { + return Err(format!("category name must be at most {MAX_CATEGORY_NAME_CHARS} characters")); + } + Ok(trimmed.to_string()) +} + +/// Accepts only `#RRGGBB` hex colors. +pub fn validate_category_color(color: &str) -> std::result::Result<(), String> { + let valid = color.len() == 7 + && color.starts_with('#') + && color[1..].chars().all(|c| c.is_ascii_hexdigit()); + if valid { + Ok(()) + } else { + Err(format!("invalid category color: '{color}'")) + } +} + +// --------------------------------------------------------------------------- +// Stats filter +// --------------------------------------------------------------------------- + +/// Categories to leave out of the stats queries. Passing `None` instead of a +/// filter means no filtering at all (the categories feature is off). +#[derive(Debug, Clone, Default, Deserialize)] +pub struct StatsFilter { + /// Category ids to leave out. + #[serde(default)] + pub hidden_ids: Vec, + /// Leave out sessions that have no category. + #[serde(default)] + pub hide_uncategorized: bool, +} + +/// SQL fragment appended to every stats query's WHERE clause. Binds +/// `:filter_on`, `:hide_uncat` and `:hidden_ids` (a JSON array of ids). +macro_rules! category_filter_sql { + () => { + " AND (:filter_on = 0 + OR (category_id IS NULL AND :hide_uncat = 0) + OR (category_id IS NOT NULL + AND category_id NOT IN (SELECT value FROM json_each(:hidden_ids))))" + }; +} + +/// Bound values for `category_filter_sql!`. +struct FilterParams { + on: bool, + hide_uncat: bool, + hidden_ids: String, +} + +impl FilterParams { + fn new(filter: Option<&StatsFilter>) -> Self { + match filter { + Some(f) => Self { + on: true, + hide_uncat: f.hide_uncategorized, + hidden_ids: serde_json::to_string(&f.hidden_ids).unwrap_or_else(|_| "[]".into()), + }, + None => Self { on: false, hide_uncat: false, hidden_ids: "[]".into() }, + } + } +} + // --------------------------------------------------------------------------- // Stats queries // --------------------------------------------------------------------------- @@ -49,23 +264,36 @@ pub struct SessionStats { pub total_work_secs: i64, } -pub fn get_all_time_stats(conn: &Connection) -> Result { +pub fn get_all_time_stats(conn: &Connection, filter: Option<&StatsFilter>) -> Result { + let f = FilterParams::new(filter); + let fp = named_params! { + ":filter_on": f.on, + ":hide_uncat": f.hide_uncat, + ":hidden_ids": f.hidden_ids, + }; + let total_work_sessions: i64 = conn.query_row( - "SELECT COUNT(*) FROM sessions WHERE round_type = 'work'", - [], + concat!("SELECT COUNT(*) FROM sessions WHERE round_type = 'work'", category_filter_sql!()), + fp, |r| r.get(0), )?; let completed_work_sessions: i64 = conn.query_row( - "SELECT COUNT(*) FROM sessions WHERE round_type = 'work' AND completed = 1", - [], + concat!( + "SELECT COUNT(*) FROM sessions WHERE round_type = 'work' AND completed = 1", + category_filter_sql!() + ), + fp, |r| r.get(0), )?; let total_work_secs: i64 = conn.query_row( - "SELECT COALESCE(SUM(duration_secs), 0) - FROM sessions WHERE round_type = 'work' AND completed = 1", - [], + concat!( + "SELECT COALESCE(SUM(duration_secs), 0) + FROM sessions WHERE round_type = 'work' AND completed = 1", + category_filter_sql!() + ), + fp, |r| r.get(0), )?; @@ -111,47 +339,65 @@ pub struct StreakInfo { } /// Completed work rounds and focus time for today (local calendar date). -pub fn get_daily_stats(conn: &Connection) -> Result { +pub fn get_daily_stats(conn: &Connection, filter: Option<&StatsFilter>) -> Result { let today: String = conn.query_row( "SELECT date('now', 'localtime')", [], |r| r.get(0), )?; + let f = FilterParams::new(filter); + let fp = named_params! { + ":today": today, + ":filter_on": f.on, + ":hide_uncat": f.hide_uncat, + ":hidden_ids": f.hidden_ids, + }; + let total: i64 = conn.query_row( - "SELECT COUNT(*) FROM sessions - WHERE round_type = 'work' - AND date(started_at, 'unixepoch', 'localtime') = ?1", - [&today], + concat!( + "SELECT COUNT(*) FROM sessions + WHERE round_type = 'work' + AND date(started_at, 'unixepoch', 'localtime') = :today", + category_filter_sql!() + ), + fp, |r| r.get(0), )?; let completed: i64 = conn.query_row( - "SELECT COUNT(*) FROM sessions - WHERE round_type = 'work' AND completed = 1 - AND date(started_at, 'unixepoch', 'localtime') = ?1", - [&today], + concat!( + "SELECT COUNT(*) FROM sessions + WHERE round_type = 'work' AND completed = 1 + AND date(started_at, 'unixepoch', 'localtime') = :today", + category_filter_sql!() + ), + fp, |r| r.get(0), )?; let focus_secs: i64 = conn.query_row( - "SELECT COALESCE(SUM(duration_secs), 0) FROM sessions - WHERE round_type = 'work' AND completed = 1 - AND date(started_at, 'unixepoch', 'localtime') = ?1", - [&today], + concat!( + "SELECT COALESCE(SUM(duration_secs), 0) FROM sessions + WHERE round_type = 'work' AND completed = 1 + AND date(started_at, 'unixepoch', 'localtime') = :today", + category_filter_sql!() + ), + fp, |r| r.get(0), )?; let mut by_hour = vec![0u32; 24]; - let mut stmt = conn.prepare( + let mut stmt = conn.prepare(concat!( "SELECT CAST(strftime('%H', datetime(started_at, 'unixepoch', 'localtime')) AS INTEGER) as h, COUNT(*) as cnt FROM sessions WHERE round_type = 'work' AND completed = 1 - AND date(started_at, 'unixepoch', 'localtime') = ?1 - GROUP BY h", - )?; - let rows = stmt.query_map([&today], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, u32>(1)?)))?; + AND date(started_at, 'unixepoch', 'localtime') = :today", + category_filter_sql!(), + " GROUP BY h" + ))?; + let rows = stmt.query_map(fp, |r| Ok((r.get::<_, i64>(0)?, r.get::<_, u32>(1)?)))?; for row in rows.flatten() { let (h, cnt) = row; if (0..24).contains(&h) { @@ -168,33 +414,54 @@ pub fn get_daily_stats(conn: &Connection) -> Result { } /// Completed work rounds per local calendar day for the last 7 days. -pub fn get_weekly_stats(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( +pub fn get_weekly_stats(conn: &Connection, filter: Option<&StatsFilter>) -> Result> { + let f = FilterParams::new(filter); + let mut stmt = conn.prepare(concat!( "SELECT date(started_at, 'unixepoch', 'localtime') as day, COUNT(*) as rounds FROM sessions WHERE round_type = 'work' AND completed = 1 - AND date(started_at, 'unixepoch', 'localtime') >= date('now', 'localtime', '-6 days') - GROUP BY day - ORDER BY day", - )?; - let rows = stmt.query_map([], |r| Ok(DayStat { date: r.get(0)?, rounds: r.get(1)? }))? + AND date(started_at, 'unixepoch', 'localtime') >= date('now', 'localtime', '-6 days')", + category_filter_sql!(), + " GROUP BY day ORDER BY day" + ))?; + let rows = stmt + .query_map( + named_params! { + ":filter_on": f.on, + ":hide_uncat": f.hide_uncat, + ":hidden_ids": f.hidden_ids, + }, + |r| Ok(DayStat { date: r.get(0)?, rounds: r.get(1)? }), + )? .collect(); rows } /// Completed work rounds per local calendar day, all time (no date limit). /// The frontend slices this into per-year views for navigation. -pub fn get_heatmap_data(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( +pub fn get_heatmap_data( + conn: &Connection, + filter: Option<&StatsFilter>, +) -> Result> { + let f = FilterParams::new(filter); + let mut stmt = conn.prepare(concat!( "SELECT date(started_at, 'unixepoch', 'localtime') as day, COUNT(*) as cnt FROM sessions - WHERE round_type = 'work' AND completed = 1 - GROUP BY day - ORDER BY day", - )?; - let rows = stmt.query_map([], |r| Ok(HeatmapEntry { date: r.get(0)?, count: r.get(1)? }))? + WHERE round_type = 'work' AND completed = 1", + category_filter_sql!(), + " GROUP BY day ORDER BY day" + ))?; + let rows = stmt + .query_map( + named_params! { + ":filter_on": f.on, + ":hide_uncat": f.hide_uncat, + ":hidden_ids": f.hidden_ids, + }, + |r| Ok(HeatmapEntry { date: r.get(0)?, count: r.get(1)? }), + )? .collect(); rows } @@ -202,22 +469,30 @@ pub fn get_heatmap_data(conn: &Connection) -> Result> { /// Current and longest work-session streaks (consecutive local calendar days). /// A streak stays active until midnight: if yesterday had sessions but today does not, /// the streak is still counted as current. -pub fn get_streak(conn: &Connection) -> Result { +pub fn get_streak(conn: &Connection, filter: Option<&StatsFilter>) -> Result { let today: String = conn.query_row( "SELECT date('now', 'localtime')", [], |r| r.get(0), )?; - let mut stmt = conn.prepare( + let f = FilterParams::new(filter); + let mut stmt = conn.prepare(concat!( "SELECT date(started_at, 'unixepoch', 'localtime') as day FROM sessions - WHERE round_type = 'work' AND completed = 1 - GROUP BY day - ORDER BY day", - )?; + WHERE round_type = 'work' AND completed = 1", + category_filter_sql!(), + " GROUP BY day ORDER BY day" + ))?; let days: Vec = stmt - .query_map([], |r| r.get(0))? + .query_map( + named_params! { + ":filter_on": f.on, + ":hide_uncat": f.hide_uncat, + ":hidden_ids": f.hidden_ids, + }, + |r| r.get(0), + )? .flatten() .collect(); @@ -310,10 +585,10 @@ mod tests { #[test] fn insert_and_complete_session() { let conn = setup(); - let id = insert_session(&conn, "work", 1500).unwrap(); + let id = insert_session(&conn, "work", 1500, None).unwrap(); assert!(id > 0); - complete_session(&conn, id, true).unwrap(); + complete_session(&conn, id, true, None).unwrap(); let completed: i64 = conn .query_row( @@ -328,7 +603,7 @@ mod tests { #[test] fn stats_empty_db() { let conn = setup(); - let stats = get_all_time_stats(&conn).unwrap(); + let stats = get_all_time_stats(&conn, None).unwrap(); assert_eq!(stats.total_work_sessions, 0); assert_eq!(stats.completed_work_sessions, 0); assert_eq!(stats.total_work_secs, 0); @@ -379,7 +654,7 @@ mod tests { #[test] fn get_daily_stats_empty() { let conn = setup(); - let stats = get_daily_stats(&conn).unwrap(); + let stats = get_daily_stats(&conn, None).unwrap(); assert_eq!(stats.rounds, 0); assert_eq!(stats.focus_mins, 0); assert!(stats.completion_rate.is_none()); @@ -389,14 +664,14 @@ mod tests { #[test] fn get_weekly_stats_empty() { let conn = setup(); - let stats = get_weekly_stats(&conn).unwrap(); + let stats = get_weekly_stats(&conn, None).unwrap(); assert!(stats.is_empty()); } #[test] fn get_heatmap_data_empty() { let conn = setup(); - let entries = get_heatmap_data(&conn).unwrap(); + let entries = get_heatmap_data(&conn, None).unwrap(); assert!(entries.is_empty()); } @@ -405,23 +680,23 @@ mod tests { let conn = setup(); // 339 s = 5:39 → rounds up to 6 min (remainder 39 ≥ 30). - let id1 = insert_session(&conn, "work", 339).unwrap(); - complete_session(&conn, id1, true).unwrap(); - let stats = get_daily_stats(&conn).unwrap(); + let id1 = insert_session(&conn, "work", 339, None).unwrap(); + complete_session(&conn, id1, true, None).unwrap(); + let stats = get_daily_stats(&conn, None).unwrap(); assert_eq!(stats.focus_mins, 6, "339 s should round to 6 min"); // Reset and test round-down: 324 s = 5:24 → rounds down to 5 min (remainder 24 < 30). let conn2 = setup(); - let id2 = insert_session(&conn2, "work", 324).unwrap(); - complete_session(&conn2, id2, true).unwrap(); - let stats2 = get_daily_stats(&conn2).unwrap(); + let id2 = insert_session(&conn2, "work", 324, None).unwrap(); + complete_session(&conn2, id2, true, None).unwrap(); + let stats2 = get_daily_stats(&conn2, None).unwrap(); assert_eq!(stats2.focus_mins, 5, "324 s should round to 5 min"); // Exact minute boundary: 1500 s = 25:00 → stays 25 min. let conn3 = setup(); - let id3 = insert_session(&conn3, "work", 1500).unwrap(); - complete_session(&conn3, id3, true).unwrap(); - let stats3 = get_daily_stats(&conn3).unwrap(); + let id3 = insert_session(&conn3, "work", 1500, None).unwrap(); + complete_session(&conn3, id3, true, None).unwrap(); + let stats3 = get_daily_stats(&conn3, None).unwrap(); assert_eq!(stats3.focus_mins, 25, "1500 s should be exactly 25 min"); } @@ -429,17 +704,180 @@ mod tests { fn stats_counts_correctly() { let conn = setup(); - let id1 = insert_session(&conn, "work", 1500).unwrap(); - complete_session(&conn, id1, true).unwrap(); + let id1 = insert_session(&conn, "work", 1500, None).unwrap(); + complete_session(&conn, id1, true, None).unwrap(); - let id2 = insert_session(&conn, "work", 1500).unwrap(); - complete_session(&conn, id2, false).unwrap(); // skipped + let id2 = insert_session(&conn, "work", 1500, None).unwrap(); + complete_session(&conn, id2, false, None).unwrap(); // skipped - let _id3 = insert_session(&conn, "short-break", 300).unwrap(); + let _id3 = insert_session(&conn, "short-break", 300, None).unwrap(); - let stats = get_all_time_stats(&conn).unwrap(); + let stats = get_all_time_stats(&conn, None).unwrap(); assert_eq!(stats.total_work_sessions, 2); assert_eq!(stats.completed_work_sessions, 1); assert_eq!(stats.total_work_secs, 1500); } + + // --- Categories --- + + fn builtin_id(conn: &Connection, key: &str) -> i64 { + conn.query_row("SELECT id FROM categories WHERE builtin_key = ?1", [key], |r| r.get(0)) + .unwrap() + } + + /// Records one completed focus round of `secs` seconds in `category`. + fn record_round(conn: &Connection, secs: u32, category: Option) { + let id = insert_session(conn, "work", secs, category).unwrap(); + complete_session(conn, id, true, category).unwrap(); + } + + #[test] + fn builtin_categories_are_listed_in_order() { + let conn = setup(); + let cats = list_categories(&conn).unwrap(); + let keys: Vec<_> = cats.iter().map(|c| c.builtin_key.as_deref()).collect(); + assert_eq!(keys, [Some("work"), Some("study"), Some("leisure")]); + assert!(cats.iter().all(|c| c.name.is_none()), "built-ins start with localized names"); + } + + #[test] + fn create_update_and_count_categories() { + let conn = setup(); + let id = create_category(&conn, "Film", "#F06595").unwrap(); + assert_eq!(count_categories(&conn).unwrap(), 4); + + let cats = list_categories(&conn).unwrap(); + let film = cats.last().unwrap(); + assert_eq!(film.id, id); + assert_eq!(film.name.as_deref(), Some("Film")); + assert_eq!(film.position, 3, "new categories are appended"); + assert!(!is_builtin_category(&conn, id).unwrap()); + + update_category(&conn, id, Some("Movies"), "#2EC4B6").unwrap(); + let film = list_categories(&conn).unwrap().pop().unwrap(); + assert_eq!(film.name.as_deref(), Some("Movies")); + assert_eq!(film.color, "#2EC4B6"); + } + + #[test] + fn delete_category_moves_rounds_to_uncategorized() { + let conn = setup(); + let film = create_category(&conn, "Film", "#F06595").unwrap(); + record_round(&conn, 1800, Some(film)); + record_round(&conn, 1800, Some(film)); + record_round(&conn, 1800, None); + assert_eq!(count_category_rounds(&conn, film).unwrap(), 2); + + let moved = delete_category(&conn, film).unwrap(); + assert_eq!(moved, 2); + assert_eq!(count_uncategorized_rounds(&conn).unwrap(), 3); + assert_eq!(count_categories(&conn).unwrap(), 3); + + // History is kept: the rounds still count toward the unfiltered totals. + let stats = get_all_time_stats(&conn, None).unwrap(); + assert_eq!(stats.completed_work_sessions, 3); + } + + #[test] + fn resolve_active_category_falls_back_to_first() { + let conn = setup(); + let work = builtin_id(&conn, "work"); + let study = builtin_id(&conn, "study"); + + assert_eq!(resolve_active_category(&conn, study).unwrap(), Some(study)); + // Unset (0) or a deleted id falls back to the first category. + assert_eq!(resolve_active_category(&conn, 0).unwrap(), Some(work)); + delete_category(&conn, study).unwrap(); + assert_eq!(resolve_active_category(&conn, study).unwrap(), Some(work)); + + // With every category deleted there is nothing to resolve to. + for c in list_categories(&conn).unwrap() { + delete_category(&conn, c.id).unwrap(); + } + assert_eq!(resolve_active_category(&conn, work).unwrap(), None); + } + + #[test] + fn stats_filter_hides_categories_and_uncategorized() { + let conn = setup(); + let work = builtin_id(&conn, "work"); + let leisure = builtin_id(&conn, "leisure"); + record_round(&conn, 1800, Some(work)); + record_round(&conn, 1800, Some(work)); + record_round(&conn, 1800, Some(leisure)); + record_round(&conn, 1800, None); + + let rounds = |filter: Option<&StatsFilter>| { + get_all_time_stats(&conn, filter).unwrap().completed_work_sessions + }; + + // No filter (feature off): everything counts. + assert_eq!(rounds(None), 4); + // Empty filter: nothing hidden. + assert_eq!(rounds(Some(&StatsFilter::default())), 4); + + let no_leisure = StatsFilter { hidden_ids: vec![leisure], hide_uncategorized: false }; + assert_eq!(rounds(Some(&no_leisure)), 3); + + let work_only = StatsFilter { hidden_ids: vec![leisure], hide_uncategorized: true }; + assert_eq!(rounds(Some(&work_only)), 2); + assert_eq!(get_daily_stats(&conn, Some(&work_only)).unwrap().rounds, 2); + assert_eq!(get_weekly_stats(&conn, Some(&work_only)).unwrap()[0].rounds, 2); + assert_eq!(get_heatmap_data(&conn, Some(&work_only)).unwrap()[0].count, 2); + assert_eq!(get_streak(&conn, Some(&work_only)).unwrap().current, 1); + + let hide_all = StatsFilter { hidden_ids: vec![work, leisure], hide_uncategorized: true }; + assert_eq!(rounds(Some(&hide_all)), 0); + assert_eq!(get_streak(&conn, Some(&hide_all)).unwrap().current, 0); + } + + #[test] + fn round_category_covers_focus_rounds_only_while_enabled() { + let conn = setup(); + let work = builtin_id(&conn, "work"); + let study = builtin_id(&conn, "study"); + + assert_eq!(round_category(&conn, true, study, "work").unwrap(), Some(study)); + assert_eq!(round_category(&conn, false, study, "work").unwrap(), None); + assert_eq!(round_category(&conn, true, study, "short-break").unwrap(), None); + assert_eq!(round_category(&conn, true, study, "long-break").unwrap(), None); + // A deleted active category falls back to the first one. + delete_category(&conn, study).unwrap(); + assert_eq!(round_category(&conn, true, study, "work").unwrap(), Some(work)); + } + + #[test] + fn unfinished_rounds_keep_their_category_in_daily_stats() { + let conn = setup(); + let work = builtin_id(&conn, "work"); + let leisure = builtin_id(&conn, "leisure"); + record_round(&conn, 1500, Some(work)); + record_round(&conn, 1500, Some(leisure)); + // A focus round that was reset before it finished. + insert_session(&conn, "work", 1500, Some(work)).unwrap(); + + let completion_with_hidden = |hidden: i64| { + let filter = StatsFilter { hidden_ids: vec![hidden], hide_uncategorized: true }; + get_daily_stats(&conn, Some(&filter)).unwrap().completion_rate + }; + // The unfinished round lowers Work's completion rate, not Leisure's. + assert_eq!(completion_with_hidden(leisure), Some(0.5)); + assert_eq!(completion_with_hidden(work), Some(1.0)); + } + + #[test] + fn category_name_and_color_validation() { + assert_eq!(validate_category_name(" Film ").unwrap(), "Film"); + assert!(validate_category_name(" ").is_err()); + assert!(validate_category_name(&"x".repeat(MAX_CATEGORY_NAME_CHARS)).is_ok()); + assert!(validate_category_name(&"x".repeat(MAX_CATEGORY_NAME_CHARS + 1)).is_err()); + // Multi-byte characters count as one each. + assert!(validate_category_name("Çalışma ve Öğrenim").is_ok()); + + assert!(validate_category_color("#4A9FF5").is_ok()); + assert!(validate_category_color("#4a9ff5").is_ok()); + assert!(validate_category_color("4A9FF5").is_err()); + assert!(validate_category_color("#4A9FF").is_err()); + assert!(validate_category_color("#4A9FFZ").is_err()); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3f4596d7..dcac56c0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,12 +22,14 @@ use commands::{ check_update, install_update, audio_clear_custom, audio_get_custom_info, audio_set_custom, + categories_create, categories_delete, categories_list, categories_round_count, + categories_update, get_log_dir, open_log_dir, notification_show, settings_get, settings_reset_defaults, settings_set, shortcuts_reload, sessions_clear, - stats_get_detailed, stats_get_heatmap, + stats_get_detailed, stats_get_heatmap, stats_uncategorized_count, themes_list, timer_get_state, timer_reset, timer_restart_round, timer_skip, timer_toggle, window_set_visibility, @@ -384,6 +386,13 @@ pub fn run() { // Stats stats_get_detailed, stats_get_heatmap, + stats_uncategorized_count, + // Categories + categories_list, + categories_create, + categories_update, + categories_delete, + categories_round_count, // Window window_set_visibility, // Shortcuts diff --git a/src-tauri/src/settings/defaults.rs b/src-tauri/src/settings/defaults.rs index 2edea8ff..2dcacbff 100644 --- a/src-tauri/src/settings/defaults.rs +++ b/src-tauri/src/settings/defaults.rs @@ -39,4 +39,7 @@ pub const DEFAULTS: &[(&str, &str)] = &[ ("local_shortcut_volume_up", "ArrowUp"), ("local_shortcut_mute", "m"), ("local_shortcut_fullscreen", "F11"), + ("categories_enabled", "false"), + ("active_category_id", "0"), + ("stats_hidden_categories", "[]"), ]; diff --git a/src-tauri/src/settings/mod.rs b/src-tauri/src/settings/mod.rs index 268c76c1..cd0b5dda 100644 --- a/src-tauri/src/settings/mod.rs +++ b/src-tauri/src/settings/mod.rs @@ -65,6 +65,14 @@ pub struct Settings { pub window_width: Option, /// Last known window height (physical pixels). `None` = use OS default. pub window_height: Option, + /// When true, completed focus rounds are recorded under the active + /// category and the category switcher and stats filter are shown. + pub categories_enabled: bool, + /// Category chosen in the titlebar switcher. 0 means none chosen yet; + /// the first category is used instead (see `queries::resolve_active_category`). + pub active_category_id: i64, + /// JSON array of category ids hidden in the stats window (0 = uncategorized). + pub stats_hidden_categories: String, } impl Default for Settings { @@ -124,6 +132,9 @@ impl Default for Settings { window_y: None, window_width: None, window_height: None, + categories_enabled: false, + active_category_id: 0, + stats_hidden_categories: "[]".to_string(), } } } @@ -249,6 +260,12 @@ pub fn load(conn: &Connection) -> Result { window_y: parse_opt_i32(&map, "window_y"), window_width: parse_opt_u32(&map, "window_width"), window_height: parse_opt_u32(&map, "window_height"), + categories_enabled: parse_bool(&map, "categories_enabled", d.categories_enabled), + active_category_id: parse_i64(&map, "active_category_id", d.active_category_id), + stats_hidden_categories: map + .get("stats_hidden_categories") + .cloned() + .unwrap_or(d.stats_hidden_categories), }) } @@ -286,6 +303,12 @@ fn parse_u32(map: &HashMap, key: &str, default: u32) -> u32 { .unwrap_or(default) } +fn parse_i64(map: &HashMap, key: &str, default: i64) -> i64 { + map.get(key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + fn parse_opt_i32(map: &HashMap, key: &str) -> Option { map.get(key)?.parse().ok() } @@ -342,6 +365,9 @@ mod tests { assert_eq!(s.theme_dark, "Pomotroid"); assert_eq!(s.language, "auto"); assert!(!s.verbose_logging); + assert!(!s.categories_enabled, "categories must be opt-in"); + assert_eq!(s.active_category_id, 0); + assert_eq!(s.stats_hidden_categories, "[]"); } #[test] @@ -426,8 +452,9 @@ mod tests { fn migration_2_converts_mins_to_secs_and_removes_old_keys() { // Simulate a pre-migration DB: schema version 1, `*_mins` keys present. let conn = Connection::open_in_memory().unwrap(); - // Run only migration 1 manually to get v1 state. - conn.execute_batch("BEGIN; CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); INSERT INTO schema_version VALUES (1); COMMIT;").unwrap(); + // Run only migration 1 manually to get v1 state. The sessions table is + // part of v1 too; later migrations (MIGRATION_7) alter it. + conn.execute_batch("BEGIN; CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, started_at INTEGER NOT NULL, ended_at INTEGER, round_type TEXT NOT NULL CHECK(round_type IN ('work', 'short-break', 'long-break')), duration_secs INTEGER NOT NULL CHECK(duration_secs > 0), completed INTEGER NOT NULL DEFAULT 0 CHECK(completed IN (0, 1))); INSERT INTO schema_version VALUES (1); COMMIT;").unwrap(); conn.execute("INSERT INTO settings (key, value) VALUES ('time_work_mins', '30')", []).unwrap(); conn.execute("INSERT INTO settings (key, value) VALUES ('time_short_break_mins', '7')", []).unwrap(); conn.execute("INSERT INTO settings (key, value) VALUES ('time_long_break_mins', '20')", []).unwrap(); diff --git a/src-tauri/src/timer/mod.rs b/src-tauri/src/timer/mod.rs index c7d6c904..a80da03a 100644 --- a/src-tauri/src/timer/mod.rs +++ b/src-tauri/src/timer/mod.rs @@ -273,8 +273,15 @@ fn listen_events( let s = settings.lock().unwrap(); seq.current_duration_secs(&s) }; + let category_settings = { + let s = settings.lock().unwrap(); + (s.categories_enabled, s.active_category_id) + }; if let Ok(conn) = db.lock() { - match queries::insert_session(&conn, &rt, total) { + // Focus rounds are filed under a category from the start, so a + // round that is reset before it finishes still counts there. + let category_id = round_category(&conn, category_settings, &rt); + match queries::insert_session(&conn, &rt, total, category_id) { Ok(id) => current_session_id = Some(id), Err(e) => log::error!("[timer] failed to record session: {e}"), } @@ -309,8 +316,17 @@ fn listen_events( // --- Session recording: mark the completed round --- if let Some(session_id) = current_session_id.take() { + let category_settings = { + let s = settings.lock().unwrap(); + (s.categories_enabled, s.active_category_id) + }; if let Ok(conn) = db.lock() { - let _ = queries::complete_session(&conn, session_id, !was_skipped); + // The category active when the round ends wins, so a round + // that was switched midway is filed under the new category. + let category_id = + round_category(&conn, category_settings, &completed_round); + let _ = + queries::complete_session(&conn, session_id, !was_skipped, category_id); } } @@ -496,6 +512,20 @@ fn listen_events( } } +/// The category a round is recorded under, given the `(categories_enabled, +/// active_category_id)` settings. A failed lookup is logged and leaves the +/// round uncategorized. +fn round_category( + conn: &rusqlite::Connection, + (enabled, active_id): (bool, i64), + round_type: &str, +) -> Option { + queries::round_category(conn, enabled, active_id, round_type).unwrap_or_else(|e| { + log::error!("[timer] failed to resolve category: {e}"); + None + }) +} + fn build_snapshot( sequence: &Arc>, settings: &Arc>, diff --git a/src/lib/components/CategorySwitcher.svelte b/src/lib/components/CategorySwitcher.svelte new file mode 100644 index 00000000..016519d9 --- /dev/null +++ b/src/lib/components/CategorySwitcher.svelte @@ -0,0 +1,248 @@ + + +
+ + + + + {#if open} + + {/if} +
+ + diff --git a/src/lib/components/Titlebar.svelte b/src/lib/components/Titlebar.svelte index 0cb6ebbe..a0c6932c 100644 --- a/src/lib/components/Titlebar.svelte +++ b/src/lib/components/Titlebar.svelte @@ -4,6 +4,7 @@ import { setWindowVisibility } from '$lib/ipc'; import { settings } from '$lib/stores/settings'; import { isMac } from '$lib/utils/platform'; + import CategorySwitcher from './CategorySwitcher.svelte'; import Tooltip from './Tooltip.svelte'; import * as m from '$paraglide/messages.js'; @@ -184,11 +185,17 @@ {#if !isMac} {@render settingsBtn()} {@render statsBtn()} + {#if $settings.categories_enabled} + + {/if} {/if}
{#if isMac} + {#if $settings.categories_enabled} + + {/if} {@render statsBtn()} {@render settingsBtn()} {:else} @@ -246,11 +253,7 @@ {/if} - + {#if paletteFor === category.id} +
+ {#each CATEGORY_COLORS as color (color)} + + {/each} +
+ {/if} +
+ + rename(category, e.currentTarget)} + onkeydown={(e) => onNameKeydown(e, category)} + /> + + + + + {#if confirming?.id === category.id} +
  • + + {confirming.rounds > 0 + ? m.categories_delete_confirm({ + name: categoryLabel(category), + count: confirming.rounds, + }) + : m.categories_delete_confirm_empty({ name: categoryLabel(category) })} + +
    + + +
    +
  • + {/if} + {/each} + + +
    + { + if (e.key === 'Enter') add(); + }} + /> + + {$categories.length} / {MAX_CATEGORIES} +
    + {#if atLimit} +

    {m.categories_limit_reached({ max: MAX_CATEGORIES })}

    + {/if} + {/if} + + + diff --git a/src/lib/components/stats/CategoryFilter.svelte b/src/lib/components/stats/CategoryFilter.svelte new file mode 100644 index 00000000..9d14d427 --- /dev/null +++ b/src/lib/components/stats/CategoryFilter.svelte @@ -0,0 +1,102 @@ + + +
    + {#each categories as category (category.id)} + {@const on = !hidden.includes(category.id)} + + {/each} + {#if showUncategorized} + {@const on = !hidden.includes(UNCATEGORIZED_ID)} + + {/if} +
    + + diff --git a/src/lib/ipc/index.ts b/src/lib/ipc/index.ts index b35c79d8..2c8889a2 100644 --- a/src/lib/ipc/index.ts +++ b/src/lib/ipc/index.ts @@ -12,6 +12,8 @@ import type { DetailedStats, HeatmapStats, UpdateInfo, + Category, + StatsFilter, } from '$lib/types'; // --- Timer commands --- @@ -80,11 +82,35 @@ export const clearSessionHistory = () => invoke('sessions_clear'); // --- Stats commands --- -/** Daily + weekly data + streak in one call (Today and This Week tabs). */ -export const statsGetDetailed = () => invoke('stats_get_detailed'); +/** Daily + weekly data + streak in one call (Today and This Week tabs). + * `filter` hides categories; null means no filtering (categories off). */ +export const statsGetDetailed = (filter: StatsFilter | null = null) => + invoke('stats_get_detailed', { filter }); /** Heatmap entries + lifetime totals (All Time tab). */ -export const statsGetHeatmap = () => invoke('stats_get_heatmap'); +export const statsGetHeatmap = (filter: StatsFilter | null = null) => + invoke('stats_get_heatmap', { filter }); + +/** Number of completed focus rounds that have no category. */ +export const statsUncategorizedCount = () => invoke('stats_uncategorized_count'); + +// --- Category commands (each mutation returns the full updated list) --- + +export const categoriesList = () => invoke('categories_list'); + +export const categoriesCreate = (name: string, color: string) => + invoke('categories_create', { name, color }); + +/** A null `name` on a built-in category restores its localized default name. */ +export const categoriesUpdate = (id: number, name: string | null, color: string) => + invoke('categories_update', { id, name, color }); + +/** Deletes a category; its rounds become uncategorized. */ +export const categoriesDelete = (id: number) => invoke('categories_delete', { id }); + +/** Completed focus rounds recorded under a category. */ +export const categoriesRoundCount = (id: number) => + invoke('categories_round_count', { id }); // --- Platform commands --- @@ -132,3 +158,6 @@ export const onThemesChanged = (cb: (themes: Theme[]) => void): Promise void): Promise => listen('sessions:cleared', () => cb()); + +export const onCategoriesChanged = (cb: (categories: Category[]) => void): Promise => + listen('categories:changed', (e) => cb(e.payload)); diff --git a/src/lib/stores/categories.ts b/src/lib/stores/categories.ts new file mode 100644 index 00000000..f6952856 --- /dev/null +++ b/src/lib/stores/categories.ts @@ -0,0 +1,18 @@ +// Categories store for the current window. +// Each window loads the list itself and follows `categories:changed`, which +// Rust emits after every create, update or delete from any window. + +import { writable } from 'svelte/store'; +import type { UnlistenFn } from '@tauri-apps/api/event'; +import { categoriesList, onCategoriesChanged } from '$lib/ipc'; +import type { Category } from '$lib/types'; + +export const categories = writable([]); + +/** Load the categories and keep the store in sync. Returns the unlisten function. */ +export async function syncCategories(): Promise { + // Subscribe first so a change made while the list loads is not missed. + const unlisten = await onCategoriesChanged((list) => categories.set(list)); + categories.set(await categoriesList()); + return unlisten; +} diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index cb9ef1b7..309cb3a7 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -43,6 +43,9 @@ const defaults: Settings = { local_shortcut_volume_up: 'ArrowUp', local_shortcut_mute: 'm', local_shortcut_fullscreen: 'F11', + categories_enabled: false, + active_category_id: 0, + stats_hidden_categories: '[]', }; export const settings = writable(defaults); diff --git a/src/lib/types.ts b/src/lib/types.ts index 88a82594..73fb9857 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -56,6 +56,27 @@ export interface Settings { local_shortcut_volume_up: string; local_shortcut_mute: string; local_shortcut_fullscreen: string; + categories_enabled: boolean; + active_category_id: number; // 0 = none chosen yet; the first category is used + stats_hidden_categories: string; // JSON array of hidden category ids; 0 = uncategorized +} + +export type BuiltinCategoryKey = 'work' | 'study' | 'leisure'; + +/** Mirrors Rust `Category`. `name` is null for a built-in category that has + * not been renamed; show its localized name instead (see `categoryLabel`). */ +export interface Category { + id: number; + name: string | null; + builtin_key: BuiltinCategoryKey | null; + color: string; // "#RRGGBB" + position: number; +} + +/** Mirrors Rust `StatsFilter` — categories to leave out of the stats. */ +export interface StatsFilter { + hidden_ids: number[]; + hide_uncategorized: boolean; } /** Returned by `check_update` — describes an available update. */ diff --git a/src/lib/utils/categories.ts b/src/lib/utils/categories.ts new file mode 100644 index 00000000..aa2e1d6a --- /dev/null +++ b/src/lib/utils/categories.ts @@ -0,0 +1,80 @@ +// Helpers shared by the category switcher, the Categories settings section +// and the stats filter. + +import type { Category, Settings, StatsFilter } from '$lib/types'; +import * as m from '$paraglide/messages.js'; + +/** Mirrors `queries::MAX_CATEGORIES` in Rust. */ +export const MAX_CATEGORIES = 10; + +/** Mirrors `queries::MAX_CATEGORY_NAME_CHARS` in Rust. */ +export const MAX_CATEGORY_NAME_CHARS = 20; + +/** Id used in `stats_hidden_categories` for rounds without a category. */ +export const UNCATEGORIZED_ID = 0; + +/** Colors offered for categories. Picked to stay readable on light and dark themes. */ +export const CATEGORY_COLORS = [ + '#4A9FF5', + '#F5A623', + '#B57EDC', + '#3DD68C', + '#FF6B6B', + '#2EC4B6', + '#F06595', + '#FFD43B', + '#A0785A', + '#94A3B8', +]; + +/** Display name: the user's name, or the localized name of a built-in category. */ +export function categoryLabel(category: Category): string { + if (category.name) return category.name; + switch (category.builtin_key) { + case 'work': + return m.category_work(); + case 'study': + return m.category_study(); + case 'leisure': + return m.category_leisure(); + default: + return ''; + } +} + +/** The category new focus rounds are filed under: the stored choice if it + * still exists, otherwise the first category. Mirrors + * `queries::resolve_active_category` in Rust. */ +export function resolveActiveCategory(categories: Category[], activeId: number): Category | null { + return categories.find((c) => c.id === activeId) ?? categories[0] ?? null; +} + +/** Parses the `stats_hidden_categories` setting, ignoring malformed values. */ +export function parseHiddenCategories(json: string): number[] { + try { + const value: unknown = JSON.parse(json); + return Array.isArray(value) ? value.filter((id) => Number.isInteger(id)) : []; + } catch { + return []; + } +} + +/** Stats filter for the current settings, or null (no filtering) when + * categories are off. */ +export function statsFilterFor(settings: Settings): StatsFilter | null { + if (!settings.categories_enabled) return null; + const hidden = parseHiddenCategories(settings.stats_hidden_categories); + return { + hidden_ids: hidden.filter((id) => id !== UNCATEGORIZED_ID), + hide_uncategorized: hidden.includes(UNCATEGORIZED_ID), + }; +} + +/** First palette color not in use yet, so a new category is easy to tell apart. */ +export function nextCategoryColor(categories: Category[]): string { + const used = new Set(categories.map((c) => c.color.toUpperCase())); + return ( + CATEGORY_COLORS.find((color) => !used.has(color)) ?? + CATEGORY_COLORS[categories.length % CATEGORY_COLORS.length] + ); +} diff --git a/src/messages/de.json b/src/messages/de.json index e7fa9739..6ed8398f 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -149,5 +149,25 @@ "tooltip_dial_countdown": "Wenn aktiviert, zählt der Fortschrittsbogen auf null herunter, anstatt sich zu füllen.", "tooltip_auto_start_work": "Die nächste Arbeitsrunde automatisch starten, wenn eine Pause endet.", "tooltip_auto_start_break": "Die Pause automatisch starten, wenn eine Arbeitsrunde endet.", - "tooltip_websocket": "Aktiviert einen lokalen WebSocket-Server für externe Integrationen wie Stream-Overlays. Standardmäßig deaktiviert." + "tooltip_websocket": "Aktiviert einen lokalen WebSocket-Server für externe Integrationen wie Stream-Overlays. Standardmäßig deaktiviert.", + "nav_categories": "Kategorien", + "categories_toggle": "Kategorien aktivieren", + "categories_toggle_desc": "Ordne jeder Fokusrunde eine Kategorie zu und sieh dir Statistiken pro Kategorie an.", + "categories_off_note": "Solange Kategorien deaktiviert sind, werden alle Runden zusammen angezeigt. Deine Kategorien bleiben erhalten und sind wieder da, wenn du sie erneut aktivierst.", + "category_work": "Arbeit", + "category_study": "Lernen", + "category_leisure": "Freizeit", + "category_uncategorized": "Ohne Kategorie", + "categories_add": "Hinzufügen", + "categories_new_placeholder": "Neue Kategorie", + "categories_limit_reached": "Du kannst bis zu {max} Kategorien haben.", + "categories_color": "Farbe ändern", + "categories_delete": "Kategorie löschen", + "categories_delete_confirm": "„{name}“ löschen? Ihre Runden ({count}) werden nach „Ohne Kategorie“ verschoben und lassen sich später nicht mehr von anderen Runden ohne Kategorie trennen.", + "categories_delete_confirm_empty": "„{name}“ löschen?", + "categories_cancel": "Abbrechen", + "categories_delete_action": "Löschen", + "categories_none": "Keine Kategorie", + "tooltip_category_switcher": "Kategorie für Fokusrunden", + "stats_filter_label": "Nach Kategorie filtern" } diff --git a/src/messages/en.json b/src/messages/en.json index 77f4f239..f43e75d8 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -149,5 +149,25 @@ "tooltip_dial_countdown": "When enabled, the progress arc counts down to zero instead of filling up.", "tooltip_auto_start_work": "Automatically start the next work round when a break ends.", "tooltip_auto_start_break": "Automatically start the break when a work round completes.", - "tooltip_websocket": "Enables a local WebSocket server for external integrations such as stream overlays. Disabled by default." + "tooltip_websocket": "Enables a local WebSocket server for external integrations such as stream overlays. Disabled by default.", + "nav_categories": "Categories", + "categories_toggle": "Enable Categories", + "categories_toggle_desc": "Tag each focus round with a category and see statistics for each one.", + "categories_off_note": "While categories are off, all rounds are shown together. Your categories are kept and come back when you turn them on again.", + "category_work": "Work", + "category_study": "Study", + "category_leisure": "Leisure", + "category_uncategorized": "Uncategorized", + "categories_add": "Add", + "categories_new_placeholder": "New category", + "categories_limit_reached": "You can have up to {max} categories.", + "categories_color": "Change color", + "categories_delete": "Delete category", + "categories_delete_confirm": "Delete “{name}”? Its rounds ({count}) will move to Uncategorized and can't be separated from other uncategorized rounds later.", + "categories_delete_confirm_empty": "Delete “{name}”?", + "categories_cancel": "Cancel", + "categories_delete_action": "Delete", + "categories_none": "No category", + "tooltip_category_switcher": "Category for focus rounds", + "stats_filter_label": "Filter by category" } diff --git a/src/messages/es.json b/src/messages/es.json index 7c1bca18..686c571e 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -149,5 +149,25 @@ "tooltip_dial_countdown": "Cuando está activado, el arco de progreso cuenta regresivamente hasta cero en lugar de llenarse.", "tooltip_auto_start_work": "Iniciar automáticamente la siguiente ronda de trabajo cuando termina un descanso.", "tooltip_auto_start_break": "Iniciar automáticamente el descanso cuando termina una ronda de trabajo.", - "tooltip_websocket": "Activa un servidor WebSocket local para integraciones externas como overlays de streaming. Desactivado por defecto." + "tooltip_websocket": "Activa un servidor WebSocket local para integraciones externas como overlays de streaming. Desactivado por defecto.", + "nav_categories": "Categorías", + "categories_toggle": "Activar categorías", + "categories_toggle_desc": "Asigna una categoría a cada ronda de enfoque y consulta estadísticas por categoría.", + "categories_off_note": "Mientras las categorías estén desactivadas, todas las rondas se muestran juntas. Tus categorías se conservan y vuelven al activarlas de nuevo.", + "category_work": "Trabajo", + "category_study": "Estudio", + "category_leisure": "Ocio", + "category_uncategorized": "Sin categoría", + "categories_add": "Añadir", + "categories_new_placeholder": "Nueva categoría", + "categories_limit_reached": "Puedes tener hasta {max} categorías.", + "categories_color": "Cambiar color", + "categories_delete": "Eliminar categoría", + "categories_delete_confirm": "¿Eliminar «{name}»? Sus rondas ({count}) pasarán a Sin categoría y después no se podrán separar de las demás rondas sin categoría.", + "categories_delete_confirm_empty": "¿Eliminar «{name}»?", + "categories_cancel": "Cancelar", + "categories_delete_action": "Eliminar", + "categories_none": "Sin categoría", + "tooltip_category_switcher": "Categoría de las rondas de enfoque", + "stats_filter_label": "Filtrar por categoría" } diff --git a/src/messages/fr.json b/src/messages/fr.json index fe7b63b8..3b6cd6a5 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -149,5 +149,25 @@ "tooltip_dial_countdown": "Lorsqu'activé, l'arc de progression compte à rebours vers zéro au lieu de se remplir.", "tooltip_auto_start_work": "Démarrer automatiquement la prochaine session de travail lorsqu'une pause se termine.", "tooltip_auto_start_break": "Démarrer automatiquement la pause lorsqu'une session de travail se termine.", - "tooltip_websocket": "Active un serveur WebSocket local pour les intégrations externes telles que les overlays de streaming. Désactivé par défaut." + "tooltip_websocket": "Active un serveur WebSocket local pour les intégrations externes telles que les overlays de streaming. Désactivé par défaut.", + "nav_categories": "Catégories", + "categories_toggle": "Activer les catégories", + "categories_toggle_desc": "Associez une catégorie à chaque ronde de concentration et consultez les statistiques de chacune.", + "categories_off_note": "Tant que les catégories sont désactivées, toutes les rondes sont affichées ensemble. Vos catégories sont conservées et reviennent quand vous les réactivez.", + "category_work": "Travail", + "category_study": "Études", + "category_leisure": "Loisirs", + "category_uncategorized": "Sans catégorie", + "categories_add": "Ajouter", + "categories_new_placeholder": "Nouvelle catégorie", + "categories_limit_reached": "Vous pouvez avoir jusqu'à {max} catégories.", + "categories_color": "Changer la couleur", + "categories_delete": "Supprimer la catégorie", + "categories_delete_confirm": "Supprimer « {name} » ? Ses rondes ({count}) passeront dans Sans catégorie et ne pourront plus être séparées des autres rondes sans catégorie.", + "categories_delete_confirm_empty": "Supprimer « {name} » ?", + "categories_cancel": "Annuler", + "categories_delete_action": "Supprimer", + "categories_none": "Aucune catégorie", + "tooltip_category_switcher": "Catégorie des rondes de concentration", + "stats_filter_label": "Filtrer par catégorie" } diff --git a/src/messages/ja.json b/src/messages/ja.json index d90a9f08..6228e4b6 100644 --- a/src/messages/ja.json +++ b/src/messages/ja.json @@ -149,5 +149,25 @@ "tooltip_dial_countdown": "有効にすると、進捗アークがゼロに向かってカウントダウンします(塗りつぶしではなく)。", "tooltip_auto_start_work": "休憩終了後に次の作業ラウンドを自動的に開始する。", "tooltip_auto_start_break": "作業ラウンド終了後に休憩を自動的に開始する。", - "tooltip_websocket": "ストリームオーバーレイなどの外部統合用のローカルWebSocketサーバーを有効にします。デフォルトでは無効です。" + "tooltip_websocket": "ストリームオーバーレイなどの外部統合用のローカルWebSocketサーバーを有効にします。デフォルトでは無効です。", + "nav_categories": "カテゴリー", + "categories_toggle": "カテゴリーを有効にする", + "categories_toggle_desc": "集中ラウンドごとにカテゴリーを付けて、カテゴリー別の統計を表示します。", + "categories_off_note": "カテゴリーが無効の間は、すべてのラウンドがまとめて表示されます。カテゴリーは保持され、再び有効にすると元に戻ります。", + "category_work": "仕事", + "category_study": "勉強", + "category_leisure": "余暇", + "category_uncategorized": "未分類", + "categories_add": "追加", + "categories_new_placeholder": "新しいカテゴリー", + "categories_limit_reached": "カテゴリーは最大 {max} 個まで作成できます。", + "categories_color": "色を変更", + "categories_delete": "カテゴリーを削除", + "categories_delete_confirm": "「{name}」を削除しますか?このカテゴリーのラウンド({count})は「未分類」に移動し、後から他の未分類のラウンドと区別できなくなります。", + "categories_delete_confirm_empty": "「{name}」を削除しますか?", + "categories_cancel": "キャンセル", + "categories_delete_action": "削除", + "categories_none": "カテゴリーなし", + "tooltip_category_switcher": "集中ラウンドのカテゴリー", + "stats_filter_label": "カテゴリーで絞り込む" } diff --git a/src/messages/pt.json b/src/messages/pt.json index bd6f7a11..82e0f23c 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -149,5 +149,25 @@ "tooltip_dial_countdown": "Quando ativado, o arco de progresso faz contagem regressiva até zero em vez de se preencher.", "tooltip_auto_start_work": "Iniciar automaticamente a próxima rodada de trabalho quando uma pausa terminar.", "tooltip_auto_start_break": "Iniciar automaticamente a pausa quando uma rodada de trabalho terminar.", - "tooltip_websocket": "Ativa um servidor WebSocket local para integrações externas como overlays de streaming. Desativado por padrão." + "tooltip_websocket": "Ativa um servidor WebSocket local para integrações externas como overlays de streaming. Desativado por padrão.", + "nav_categories": "Categorias", + "categories_toggle": "Ativar Categorias", + "categories_toggle_desc": "Marque cada rodada de foco com uma categoria e veja estatísticas de cada uma.", + "categories_off_note": "Enquanto as categorias estiverem desativadas, todas as rodadas são mostradas juntas. Suas categorias são mantidas e voltam quando você reativá-las.", + "category_work": "Trabalho", + "category_study": "Estudo", + "category_leisure": "Lazer", + "category_uncategorized": "Sem categoria", + "categories_add": "Adicionar", + "categories_new_placeholder": "Nova categoria", + "categories_limit_reached": "Você pode ter até {max} categorias.", + "categories_color": "Alterar cor", + "categories_delete": "Excluir categoria", + "categories_delete_confirm": "Excluir “{name}”? As rodadas dela ({count}) irão para Sem categoria e depois não poderão ser separadas das outras rodadas sem categoria.", + "categories_delete_confirm_empty": "Excluir “{name}”?", + "categories_cancel": "Cancelar", + "categories_delete_action": "Excluir", + "categories_none": "Sem categoria", + "tooltip_category_switcher": "Categoria das rodadas de foco", + "stats_filter_label": "Filtrar por categoria" } diff --git a/src/messages/tr.json b/src/messages/tr.json index a950c011..c5ccde21 100644 --- a/src/messages/tr.json +++ b/src/messages/tr.json @@ -149,5 +149,25 @@ "tooltip_dial_countdown": "Etkinleştirildiğinde, ilerleme yayı dolmak yerine sıfıra doğru geri sayar.", "tooltip_auto_start_work": "Bir mola bittiğinde sonraki çalışma turunu otomatik olarak başlat.", "tooltip_auto_start_break": "Bir çalışma turu tamamlandığında molayı otomatik olarak başlat.", - "tooltip_websocket": "Yayın yerleşimleri gibi harici entegrasyonlar için yerel bir WebSocket sunucusu etkinleştirir. Varsayılan olarak devre dışıdır." + "tooltip_websocket": "Yayın yerleşimleri gibi harici entegrasyonlar için yerel bir WebSocket sunucusu etkinleştirir. Varsayılan olarak devre dışıdır.", + "nav_categories": "Kategoriler", + "categories_toggle": "Kategorileri Etkinleştir", + "categories_toggle_desc": "Her odaklanma turunu bir kategoriyle etiketleyin ve istatistikleri kategori bazında görün.", + "categories_off_note": "Kategoriler kapalıyken tüm turlar birlikte gösterilir. Kategorileriniz silinmez; tekrar açtığınızda geri gelir.", + "category_work": "İş", + "category_study": "Ders", + "category_leisure": "Boş Zaman", + "category_uncategorized": "Kategorisiz", + "categories_add": "Ekle", + "categories_new_placeholder": "Yeni kategori", + "categories_limit_reached": "En fazla {max} kategori oluşturabilirsiniz.", + "categories_color": "Rengi değiştir", + "categories_delete": "Kategoriyi sil", + "categories_delete_confirm": "“{name}” silinsin mi? Bu kategorideki turlar ({count}) Kategorisiz'e taşınacak ve daha sonra diğer kategorisiz turlardan ayrılamayacak.", + "categories_delete_confirm_empty": "“{name}” silinsin mi?", + "categories_cancel": "İptal", + "categories_delete_action": "Sil", + "categories_none": "Kategori yok", + "tooltip_category_switcher": "Odaklanma turlarının kategorisi", + "stats_filter_label": "Kategoriye göre filtrele" } diff --git a/src/messages/zh.json b/src/messages/zh.json index 28ea6821..8c8a051f 100644 --- a/src/messages/zh.json +++ b/src/messages/zh.json @@ -149,5 +149,25 @@ "tooltip_dial_countdown": "启用后,进度弧从满倒计时至零,而非从零填充。", "tooltip_auto_start_work": "休息结束后自动开始下一个工作轮次。", "tooltip_auto_start_break": "工作轮次结束后自动开始休息。", - "tooltip_websocket": "启用本地 WebSocket 服务器,用于直播覆盖层等外部集成。默认禁用。" + "tooltip_websocket": "启用本地 WebSocket 服务器,用于直播覆盖层等外部集成。默认禁用。", + "nav_categories": "分类", + "categories_toggle": "启用分类", + "categories_toggle_desc": "为每个专注轮次标记分类,并按分类查看统计。", + "categories_off_note": "分类关闭时,所有轮次会合并显示。你的分类会保留,重新启用后即可恢复。", + "category_work": "工作", + "category_study": "学习", + "category_leisure": "休闲", + "category_uncategorized": "未分类", + "categories_add": "添加", + "categories_new_placeholder": "新分类", + "categories_limit_reached": "最多可以有 {max} 个分类。", + "categories_color": "更改颜色", + "categories_delete": "删除分类", + "categories_delete_confirm": "删除“{name}”?该分类下的轮次({count})将移至“未分类”,之后无法再与其他未分类轮次区分。", + "categories_delete_confirm_empty": "删除“{name}”?", + "categories_cancel": "取消", + "categories_delete_action": "删除", + "categories_none": "无分类", + "tooltip_category_switcher": "专注轮次的分类", + "stats_filter_label": "按分类筛选" } diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index e3d18d92..ec51122f 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -13,6 +13,7 @@ import SettingsTitlebar from '$lib/components/settings/SettingsTitlebar.svelte'; import TimerSection from '$lib/components/settings/sections/TimerSection.svelte'; + import CategoriesSection from '$lib/components/settings/sections/CategoriesSection.svelte'; import AppearanceSection from '$lib/components/settings/sections/AppearanceSection.svelte'; import NotificationsSection from '$lib/components/settings/sections/NotificationsSection.svelte'; import ShortcutsSection from '$lib/components/settings/sections/ShortcutsSection.svelte'; @@ -21,10 +22,18 @@ import * as m from '$paraglide/messages.js'; - type Section = 'timer' | 'appearance' | 'notifications' | 'shortcuts' | 'system' | 'about'; + type Section = + | 'timer' + | 'categories' + | 'appearance' + | 'notifications' + | 'shortcuts' + | 'system' + | 'about'; const SECTIONS: { id: Section; label: () => string }[] = [ { id: 'timer', label: m.nav_timer }, + { id: 'categories', label: m.nav_categories }, { id: 'appearance', label: m.nav_appearance }, { id: 'notifications', label: m.nav_notifications }, { id: 'shortcuts', label: m.nav_shortcuts }, @@ -157,6 +166,8 @@
    {#if active === 'timer'} + {:else if active === 'categories'} + {:else if active === 'appearance'} {:else if active === 'notifications'} diff --git a/src/routes/stats/+page.svelte b/src/routes/stats/+page.svelte index a0b37b47..f64fe3a0 100644 --- a/src/routes/stats/+page.svelte +++ b/src/routes/stats/+page.svelte @@ -4,14 +4,19 @@ import { getSettings, getThemes, + onCategoriesChanged, onSettingsChanged, onThemesChanged, onRoundChange, onSessionsCleared, + setSetting, statsGetDetailed, statsGetHeatmap, + statsUncategorizedCount, } from '$lib/ipc'; import { settings } from '$lib/stores/settings'; + import { categories, syncCategories } from '$lib/stores/categories'; + import { parseHiddenCategories, statsFilterFor } from '$lib/utils/categories'; import { applyTheme } from '$lib/stores/theme'; import { setLocale } from '$lib/locale.svelte.js'; import { resolveThemeName } from '$lib/utils/theme'; @@ -25,6 +30,7 @@ import DailyView from '$lib/components/stats/DailyView.svelte'; import WeeklyView from '$lib/components/stats/WeeklyView.svelte'; import YearlyView from '$lib/components/stats/YearlyView.svelte'; + import CategoryFilter from '$lib/components/stats/CategoryFilter.svelte'; type Tab = 'today' | 'week' | 'alltime'; @@ -32,12 +38,15 @@ let detailed = $state(null); let heatmap = $state(null); let heatmapLoaded = $state(false); + let uncategorizedCount = $state(0); + + const hiddenCategories = $derived(parseHiddenCategories($settings.stats_hidden_categories)); async function switchTab(tab: Tab) { activeTab = tab; if (tab === 'alltime' && !heatmapLoaded) { try { - heatmap = await statsGetHeatmap(); + heatmap = await statsGetHeatmap(statsFilterFor($settings)); heatmapLoaded = true; } catch (e) { await logError(`[stats] failed to load heatmap: ${e}`); @@ -45,6 +54,27 @@ } } + /** Reload every loaded view with the current category filter. */ + async function refresh(reason: string) { + try { + const filter = statsFilterFor($settings); + detailed = await statsGetDetailed(filter); + if (heatmapLoaded) heatmap = await statsGetHeatmap(filter); + if ($settings.categories_enabled) uncategorizedCount = await statsUncategorizedCount(); + } catch (e) { + await logError(`[stats] failed to refresh stats after ${reason}: ${e}`); + } + } + + /** Show or hide one category (or uncategorized rounds) in the stats. */ + async function toggleCategory(id: number) { + const next = hiddenCategories.includes(id) + ? hiddenCategories.filter((h) => h !== id) + : [...hiddenCategories, id]; + settings.set(await setSetting('stats_hidden_categories', JSON.stringify(next))); + await refresh('filter change'); + } + function close() { getCurrentWebviewWindow().close(); } @@ -67,7 +97,8 @@ // Show window immediately after theme is applied await getCurrentWebviewWindow().show(); - detailed = await statsGetDetailed(); + cleanups.push(await syncCategories()); + await refresh('initial load'); await info(`[stats] initialized, theme=${activeTheme?.name ?? 'none'}`); } catch (e) { await logError(`[stats] initialization failed: ${e}`); @@ -75,33 +106,29 @@ } cleanups.push( - await onRoundChange(async () => { - try { - detailed = await statsGetDetailed(); - if (heatmapLoaded) heatmap = await statsGetHeatmap(); - } catch (e) { - await logError(`[stats] failed to refresh stats after round change: ${e}`); - } - }), - await onSessionsCleared(async () => { - try { - detailed = await statsGetDetailed(); - if (heatmapLoaded) heatmap = await statsGetHeatmap(); - } catch (e) { - await logError(`[stats] failed to refresh stats after session clear: ${e}`); - } - }), + await onRoundChange(() => refresh('round change')), + await onSessionsCleared(() => refresh('session clear')), + // Deleting a category moves its rounds to uncategorized. + await onCategoriesChanged(() => refresh('category change')), await onSettingsChanged(async (updated) => { const prev = { mode: $settings.theme_mode, light: $settings.theme_light, dark: $settings.theme_dark, language: $settings.language, + categoriesEnabled: $settings.categories_enabled, + hiddenCategories: $settings.stats_hidden_categories, }; settings.set(updated); if (updated.language !== prev.language) { setLocale(updated.language); } + if ( + updated.categories_enabled !== prev.categoriesEnabled || + updated.stats_hidden_categories !== prev.hiddenCategories + ) { + await refresh('settings change'); + } if ( updated.theme_mode !== prev.mode || updated.theme_light !== prev.light || @@ -171,6 +198,15 @@ > + {#if $settings.categories_enabled && ($categories.length > 0 || uncategorizedCount > 0)} +