feat(gamification): migration v13 — auto_completed column

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 20:08:18 +01:00
parent d5eb212246
commit 729b82cf50

View File

@@ -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<String> = info.iter().map(|r| r.get::<String, _>("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");
}
}