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
|
# Async runtime
|
||||||
tokio = { version = "1", features = ["rt", "sync", "macros"] }
|
tokio = { version = "1", features = ["rt", "sync", "macros"] }
|
||||||
|
|
||||||
|
# Serialisation (DailyCompletionCount exposed to frontend via Tauri commands)
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
|
||||||
|
|||||||
@@ -533,6 +533,80 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
|||||||
Ok(())
|
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 ---
|
// --- Implementation intentions ---
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[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_done, 0, "parent reopens when child is uncompleted");
|
||||||
assert_eq!(parent_auto, 0, "auto_completed must clear on reopen");
|
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,
|
delete_transcript, get_implementation_rule, get_profile, get_setting, get_task_by_id,
|
||||||
get_transcript, init, insert_implementation_rule, insert_subtask, insert_task,
|
get_transcript, init, insert_implementation_rule, insert_subtask, insert_task,
|
||||||
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
|
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
|
||||||
list_profiles, list_recent_errors, list_subtasks, list_tasks, list_transcripts,
|
list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks,
|
||||||
list_transcripts_paged, log_error, mark_implementation_rule_fired, record_feedback,
|
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
|
||||||
search_transcripts, set_implementation_rule_enabled, set_setting, set_task_energy,
|
record_feedback, search_transcripts, set_implementation_rule_enabled, set_setting,
|
||||||
uncomplete_task, update_profile, update_task, update_transcript, update_transcript_meta,
|
set_task_energy, uncomplete_task, update_profile, update_task, update_transcript,
|
||||||
ErrorLogRow, FeedbackRow, FeedbackTargetType, ImplementationRuleRow, InsertTranscriptParams,
|
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
|
||||||
ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskRow, TranscriptRow,
|
ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
|
||||||
|
RecordFeedbackParams, TaskRow, TranscriptRow,
|
||||||
};
|
};
|
||||||
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
||||||
|
|||||||
Reference in New Issue
Block a user