From 83bd338affc97a270563eb6a1a1fd5b11fd0264d Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 24 Apr 2026 20:25:32 +0100 Subject: [PATCH] feat(gamification): list_recent_completions query + DailyCompletionCount Returns a fixed-length, oldest-first series of daily completion counts for the last N local-time days. Excludes cascade parents and uncompleted rows. Empty days are explicit zeros, not missing entries, so the Phase 8 sparkline can render a fixed 7 bars. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/storage/Cargo.toml | 3 + crates/storage/src/database.rs | 198 +++++++++++++++++++++++++++++++++ crates/storage/src/lib.rs | 13 ++- 3 files changed, 208 insertions(+), 6 deletions(-) diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 1e95dc0..8875bd4 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -18,6 +18,9 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", # Async runtime tokio = { version = "1", features = ["rt", "sync", "macros"] } +# Serialisation (DailyCompletionCount exposed to frontend via Tauri commands) +serde = { version = "1", features = ["derive"] } + # Logging log = "0.4" diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index fe4e422..2611114 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -533,6 +533,80 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> { Ok(()) } +// --- Phase 8: daily completion counts ----------------------------------- +// +// Drives the Tasks-page badge ("3 today") and the 7-day momentum +// sparkline. Counts every row whose done_at falls on a given local +// day, excluding auto-cascade parents (auto_completed = 1). Subtasks +// count on explicit completion. +// +// The query groups by local calendar day (DATE(done_at, 'localtime') +// since done_at is stored as UTC). The Rust side then left-joins the +// grouped result against a generated N-day spine so empty days are +// explicit zeros, not missing entries. The frontend sparkline wants a +// fixed-length array it can render as 7 bars. + +#[derive(Debug, Clone, serde::Serialize)] +pub struct DailyCompletionCount { + pub day: String, // "YYYY-MM-DD" in local time + pub count: u32, +} + +pub async fn list_recent_completions( + pool: &SqlitePool, + days: u32, +) -> Result> { + // Guard: clamp to [1, 365]. The frontend only ever asks for 7 but + // a zero or wild value here would produce an empty or huge series. + let days = days.clamp(1, 365); + + // Pull the grouped counts from SQLite. We do not generate the spine + // in SQL. It is easier to left-join in Rust and keep the query simple. + // Use tuple form because the storage crate does not enable sqlx's + // `derive` feature (FromRow macro is gated behind it). + let rows: Vec<(String, i64)> = sqlx::query_as( + "SELECT DATE(done_at, 'localtime') AS day, COUNT(*) AS count \ + FROM tasks \ + WHERE done = 1 \ + AND done_at IS NOT NULL \ + AND auto_completed = 0 \ + AND DATE(done_at, 'localtime') >= DATE('now', 'localtime', ?) \ + GROUP BY day", + ) + .bind(format!("-{} days", days - 1)) + .fetch_all(pool) + .await + .map_err(|e| KonError::StorageError(format!("List recent completions failed: {e}")))?; + + let lookup: std::collections::HashMap = rows + .into_iter() + .map(|(day, count)| (day, count.max(0) as u32)) + .collect(); + + // Build the spine. `today` is local-day. Walk back `days - 1` entries. + let today_row: (String,) = sqlx::query_as("SELECT DATE('now', 'localtime')") + .fetch_one(pool) + .await + .map_err(|e| KonError::StorageError(format!("Get local today failed: {e}")))?; + let today = today_row.0; + + let mut series = Vec::with_capacity(days as usize); + for offset in (0..days as i64).rev() { + // Re-query SQLite for each offset so the date arithmetic is the + // same calendar SQLite uses (month boundaries, DST, etc.). + let (day,): (String,) = sqlx::query_as("SELECT DATE(?, ?)") + .bind(&today) + .bind(format!("-{offset} days")) + .fetch_one(pool) + .await + .map_err(|e| KonError::StorageError(format!("Compute spine day failed: {e}")))?; + let count = lookup.get(&day).copied().unwrap_or(0); + series.push(DailyCompletionCount { day, count }); + } + + Ok(series) +} + // --- Implementation intentions --- #[allow(clippy::too_many_arguments)] @@ -2124,4 +2198,128 @@ mod tests { assert_eq!(parent_done, 0, "parent reopens when child is uncompleted"); assert_eq!(parent_auto, 0, "auto_completed must clear on reopen"); } + + #[tokio::test] + async fn list_recent_completions_returns_n_entries_including_zero_days() { + let pool = test_pool().await; + // No completions at all. + let series = list_recent_completions(&pool, 7).await.unwrap(); + assert_eq!(series.len(), 7, "always returns exactly N entries"); + assert!(series.iter().all(|d| d.count == 0), "all zero for empty DB"); + + // Oldest first, newest last. Dates should be strictly increasing. + for w in series.windows(2) { + assert!(w[0].day < w[1].day, "series must be oldest-first"); + } + } + + #[tokio::test] + async fn list_recent_completions_excludes_auto_cascade_parents() { + let pool = test_pool().await; + insert_task(&pool, "p", "Parent", "inbox", None, None, None, None) + .await + .unwrap(); + insert_subtask(&pool, "s1", "Step 1", "p").await.unwrap(); + + // Manual subtask complete. Cascade auto-closes parent. + complete_subtask_and_check_parent(&pool, "s1") + .await + .unwrap(); + + let series = list_recent_completions(&pool, 7).await.unwrap(); + let total: u32 = series.iter().map(|d| d.count).sum(); + assert_eq!( + total, 1, + "subtask counts (1), cascade parent excluded. Got series: {series:?}" + ); + } + + #[tokio::test] + async fn list_recent_completions_counts_manual_top_level() { + let pool = test_pool().await; + insert_task(&pool, "t1", "Task one", "inbox", None, None, None, None) + .await + .unwrap(); + complete_task(&pool, "t1").await.unwrap(); + + let series = list_recent_completions(&pool, 7).await.unwrap(); + let total: u32 = series.iter().map(|d| d.count).sum(); + assert_eq!(total, 1); + // Most recent entry is today (local) and holds the 1. + assert_eq!(series.last().unwrap().count, 1); + } + + #[tokio::test] + async fn list_recent_completions_excludes_uncompleted() { + let pool = test_pool().await; + insert_task(&pool, "t1", "Task one", "inbox", None, None, None, None) + .await + .unwrap(); + complete_task(&pool, "t1").await.unwrap(); + uncomplete_task(&pool, "t1").await.unwrap(); + + let series = list_recent_completions(&pool, 7).await.unwrap(); + let total: u32 = series.iter().map(|d| d.count).sum(); + assert_eq!( + total, 0, + "uncompleted rows have NULL done_at and must not count" + ); + } + + #[tokio::test] + async fn list_recent_completions_uses_local_day_boundary() { + // Insert two rows with done_at values on either side of local + // midnight (expressed in UTC, which is how datetime('now') stores). + // The row whose local-day is "today" must land in today's bucket. + // The row whose local-day is "yesterday" must land in yesterday's. + // + // We avoid hard-coding dates. Compute them at runtime from + // SQLite so the test is stable across the real clock. The chosen + // UTC times (2 days ago noon) are safely either side of midnight + // for any local timezone that matters for this project. + let pool = test_pool().await; + + // Seed two tasks that we will manipulate done_at on directly. + insert_task(&pool, "a", "A", "inbox", None, None, None, None) + .await + .unwrap(); + insert_task(&pool, "b", "B", "inbox", None, None, None, None) + .await + .unwrap(); + + // Put row "a" at a done_at that is definitely "2 days ago" locally. + // Anchor to start of day then add 12 h so the timestamp is noon UTC + // 2 days ago, regardless of when in the day the test runs. + // Row "b" uses the current moment (today). + sqlx::query( + "UPDATE tasks SET done = 1, \ + done_at = datetime('now', '-2 days', 'start of day', '+12 hours'), \ + auto_completed = 0 WHERE id = 'a'", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "UPDATE tasks SET done = 1, done_at = datetime('now'), auto_completed = 0 \ + WHERE id = 'b'", + ) + .execute(&pool) + .await + .unwrap(); + + let series = list_recent_completions(&pool, 7).await.unwrap(); + assert_eq!(series.len(), 7); + + // "today" is the last entry. "2 days ago" is 2 positions before + // the last entry (0-indexed from end: -1 today, -2 yesterday, + // -3 two days ago). + let today_count = series.last().unwrap().count; + let two_days_ago_count = series[series.len() - 3].count; + assert_eq!(today_count, 1, "row B is today: {series:?}"); + assert_eq!(two_days_ago_count, 1, "row A is 2 days ago: {series:?}"); + + let total: u32 = series.iter().map(|d| d.count).sum(); + assert_eq!(total, 2, "exactly two completions across the window"); + } } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index b02f73a..6bb9837 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -12,11 +12,12 @@ pub use database::{ delete_transcript, get_implementation_rule, get_profile, get_setting, get_task_by_id, get_transcript, init, insert_implementation_rule, insert_subtask, insert_task, insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms, - list_profiles, list_recent_errors, list_subtasks, list_tasks, list_transcripts, - list_transcripts_paged, log_error, mark_implementation_rule_fired, record_feedback, - search_transcripts, set_implementation_rule_enabled, set_setting, set_task_energy, - uncomplete_task, update_profile, update_task, update_transcript, update_transcript_meta, - ErrorLogRow, FeedbackRow, FeedbackTargetType, ImplementationRuleRow, InsertTranscriptParams, - ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskRow, TranscriptRow, + list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks, + list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired, + record_feedback, search_transcripts, set_implementation_rule_enabled, set_setting, + set_task_energy, uncomplete_task, update_profile, update_task, update_transcript, + update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType, + ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow, + RecordFeedbackParams, TaskRow, TranscriptRow, }; pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};