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) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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<Vec<DailyCompletionCount>> {
|
||||
// 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<String, u32> = 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user