From 729b82cf505040500ac9a0489b1df700fa39bb8a Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 24 Apr 2026 20:08:18 +0100 Subject: [PATCH] =?UTF-8?q?feat(gamification):=20migration=20v13=20?= =?UTF-8?q?=E2=80=94=20auto=5Fcompleted=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a flag on tasks to distinguish manual completions from the cascade auto-completion performed by complete_subtask_and_check_parent. Partial index on (done_at, auto_completed) supports the Phase 8 daily-count query without bloating the tasks index footprint. Forward-only: pre-migration completed rows default to 0 (they count). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/storage/src/migrations.rs | 62 ++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index 3c96520..7845e9c 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -430,6 +430,27 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ ON implementation_rules(enabled, trigger_kind); "#, ), + ( + 13, + "gamification: auto_completed flag for cascade-completed parents", + r#" + -- Phase 8 of the feature-complete roadmap. Parents that close via + -- the complete_subtask_and_check_parent cascade must not count + -- towards daily completion totals. The user already got credit + -- for ticking the subtask. This column distinguishes manual + -- completions (0) from cascade completions (1). The daily-count + -- query then excludes auto_completed = 1. + -- + -- Partial index keeps the index small: only completed rows occupy + -- it, since uncompleted rows have done_at IS NULL. + ALTER TABLE tasks ADD COLUMN auto_completed INTEGER NOT NULL DEFAULT 0 + CHECK (auto_completed IN (0, 1)); + + CREATE INDEX idx_tasks_done_at_auto_completed + ON tasks(done_at, auto_completed) + WHERE done_at IS NOT NULL; + "#, + ), ]; /// Split SQL into individual statements, respecting BEGIN...END trigger blocks. @@ -579,7 +600,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 12); + assert_eq!(count, 13); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -598,7 +619,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 12); + assert_eq!(count, 13); } #[tokio::test] @@ -1054,4 +1075,41 @@ mod tests { "schema_version must not advance past the failed migration" ); } + + #[tokio::test] + async fn migration_v13_adds_auto_completed_column() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("pool"); + run_migrations(&pool).await.expect("migrate"); + + // Column exists. + let info = sqlx::query("PRAGMA table_info(tasks)") + .fetch_all(&pool) + .await + .expect("pragma"); + let names: Vec = info.iter().map(|r| r.get::("name")).collect(); + assert!( + names.iter().any(|n| n == "auto_completed"), + "expected auto_completed column, got {names:?}" + ); + + // Existing completed rows default to 0. Insert a pre-existing-looking + // task via raw SQL to simulate a row from before the migration. + sqlx::query( + "INSERT INTO tasks (id, text, bucket, done, done_at) \ + VALUES ('t1', 'pre-existing', 'inbox', 1, '2026-04-20 12:00:00')", + ) + .execute(&pool) + .await + .expect("insert"); + + let auto: i64 = sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 't1'") + .fetch_one(&pool) + .await + .expect("query"); + assert_eq!(auto, 0, "pre-existing completed rows must default to 0"); + } }