diff --git a/docs/superpowers/plans/2026-04-24-phase8-forgiving-gamification.md b/docs/superpowers/plans/2026-04-24-phase8-forgiving-gamification.md new file mode 100644 index 0000000..06631d4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-phase8-forgiving-gamification.md @@ -0,0 +1,1269 @@ +# Phase 8 — Forgiving Gamification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add today's completion count + a 7-day momentum sparkline to the Tasks page header. No streaks, no loss language, excludes auto-cascade parents. + +**Architecture:** New migration v13 adds an `auto_completed` column so cascade-completed parents can be excluded from counts. A new Rust storage fn + Tauri command returns a 7-entry daily series including zero-days. A new Svelte 5 store drives a badge and inline SVG sparkline in the Tasks header, refreshed on mutation events and window focus. Settings toggle default-on for the sparkline; badge always on. + +**Tech Stack:** Rust + sqlx + SQLite (storage crate), Tauri 2 commands, Svelte 5 runes, TypeScript, Tailwind v4. + +**Spec:** [docs/superpowers/specs/2026-04-24-phase8-forgiving-gamification-design.md](../specs/2026-04-24-phase8-forgiving-gamification-design.md) + +--- + +## Conventions + +- Branch: `main` (Jake's standing rule — phases ship on main, not feature branches). +- Commit after each task. Format: `feat(gamification): [what changed]`, or `fix(gamification): …` for a within-phase correction. Each task's final step is the commit. +- Test runner on Rust: `cargo test -p kon-storage` (or crate name in workspace). Frontend has **no test runner** — `npm run check` is the only type/lint gate; correctness is verified by cargo tests on the backend and manual dogfood on the frontend, deferred to Phase 10a QC. +- British English in user-facing copy. +- No em / en dashes in prose or strings. + +--- + +## File Structure + +**Files created:** +- `src/lib/stores/completionStats.svelte.ts` — store for 7-day completion data. +- `src/lib/components/CompletionSparkline.svelte` — SVG sparkline component. + +**Files modified:** +- `crates/storage/src/migrations.rs` — add migration v13 entry. +- `crates/storage/src/database.rs` — tweak `complete_subtask_and_check_parent` cascade UPDATE, tweak `uncomplete_task` UPDATE, add `DailyCompletionCount` struct + `list_recent_completions` fn + tests. +- `src-tauri/src/commands/tasks.rs` — add `list_recent_completions_cmd` Tauri wrapper. +- `src-tauri/src/lib.rs` — register the new command in the `invoke_handler!` block. +- `src/lib/types/app.ts` — add `DailyCompletionCount` type; add `showMomentumSparkline: boolean` to the settings type; default to `true`. +- `src/lib/stores/page.svelte.ts` — add `completionStats.refresh()` call inside `uncompleteTask` and `deleteTask`. Expose/import the store if needed. Also ensure `showMomentumSparkline` is hydrated from/written to the settings row. +- `src/lib/pages/TasksPage.svelte` — header markup for badge + sparkline, subscribe to completionStats. +- `src/lib/pages/SettingsPage.svelte` — add the "Show momentum sparkline" toggle. + +**No files deleted.** + +--- + +### Task 1: Migration v13 — add `auto_completed` column + +**Files:** +- Modify: `crates/storage/src/migrations.rs` (append to the `MIGRATIONS` array; currently ends at v12). +- Test: `crates/storage/src/migrations.rs` (existing `#[cfg(test)]` block at bottom of file). + +- [ ] **Step 1: Write the failing test** + +Append to the existing `#[cfg(test)] mod tests` block in `crates/storage/src/migrations.rs`: + +```rust +#[tokio::test] +async fn migration_v13_adds_auto_completed_column() { + use sqlx::Row; + use sqlx::sqlite::SqlitePoolOptions; + + let pool = SqlitePoolOptions::new() + .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"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p kon-storage migration_v13 -- --nocapture` +Expected: FAIL with "no such column: auto_completed" or "expected auto_completed column" depending on which assertion fires first. + +- [ ] **Step 3: Add the migration entry** + +In `crates/storage/src/migrations.rs`, append to the `MIGRATIONS: &[(i64, &str, &str)]` slice (after the v12 implementation_rules entry) **before the closing `];`**: + +```rust +( + 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; + "#, +), +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p kon-storage migration_v13 -- --nocapture` +Expected: PASS. + +- [ ] **Step 5: Run the full storage test suite to confirm no regression** + +Run: `cargo test -p kon-storage` +Expected: all existing tests still pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/storage/src/migrations.rs +git commit -m "$(cat <<'EOF' +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) +EOF +)" +``` + +--- + +### Task 2: Cascade UPDATE sets `auto_completed = 1` + +**Files:** +- Modify: `crates/storage/src/database.rs:454` (the parent UPDATE inside `complete_subtask_and_check_parent`). +- Test: `crates/storage/src/database.rs` (existing `#[cfg(test)] mod tests` block). + +- [ ] **Step 1: Write the failing test** + +Append to the existing `#[cfg(test)] mod tests` in `crates/storage/src/database.rs` (near the existing subtask cascade tests): + +```rust +#[tokio::test] +async fn cascade_sets_auto_completed_on_parent_only() { + let pool = test_pool().await; + + // Parent + two subtasks. + insert_task( + &pool, "parent", "Parent task", "inbox", None, None, None, None, + ).await.unwrap(); + insert_subtask(&pool, "s1", "Step 1", "parent").await.unwrap(); + insert_subtask(&pool, "s2", "Step 2", "parent").await.unwrap(); + + complete_subtask_and_check_parent(&pool, "s1").await.unwrap(); + complete_subtask_and_check_parent(&pool, "s2").await.unwrap(); + + // Parent auto-closed. + let parent_auto: i64 = sqlx::query_scalar( + "SELECT auto_completed FROM tasks WHERE id = 'parent'", + ) + .fetch_one(&pool).await.unwrap(); + assert_eq!(parent_auto, 1, "parent closed by cascade must be auto_completed = 1"); + + // Subtasks themselves are manual. + let s1_auto: i64 = sqlx::query_scalar( + "SELECT auto_completed FROM tasks WHERE id = 's1'", + ) + .fetch_one(&pool).await.unwrap(); + let s2_auto: i64 = sqlx::query_scalar( + "SELECT auto_completed FROM tasks WHERE id = 's2'", + ) + .fetch_one(&pool).await.unwrap(); + assert_eq!(s1_auto, 0, "subtask completion is manual"); + assert_eq!(s2_auto, 0, "subtask completion is manual"); +} +``` + +(If `insert_subtask` has a different signature in this codebase, match it — look at the existing subtask cascade tests in the same file for the exact call shape. Do not invent arguments.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p kon-storage cascade_sets_auto_completed -- --nocapture` +Expected: FAIL — parent_auto is 0 because the cascade UPDATE doesn't set the flag yet. + +- [ ] **Step 3: Modify the cascade UPDATE** + +In `crates/storage/src/database.rs` inside `complete_subtask_and_check_parent`, change the parent auto-complete UPDATE (currently line 454): + +**Before:** +```rust +if pending == 0 { + sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?") + .bind(&pid) + .execute(&mut *tx) + .await + .map_err(|e| KonError::StorageError(format!("Auto-complete parent failed: {e}")))?; +} +``` + +**After:** +```rust +if pending == 0 { + // Phase 8: flag the cascade so the daily-count query can exclude + // it. The subtask UPDATE (above) stays at auto_completed = 0 — the + // user explicitly ticked it, so it counts. + sqlx::query( + "UPDATE tasks SET done = 1, done_at = datetime('now'), auto_completed = 1 \ + WHERE id = ?", + ) + .bind(&pid) + .execute(&mut *tx) + .await + .map_err(|e| KonError::StorageError(format!("Auto-complete parent failed: {e}")))?; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p kon-storage cascade_sets_auto_completed -- --nocapture` +Expected: PASS. + +- [ ] **Step 5: Run the full storage test suite** + +Run: `cargo test -p kon-storage` +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/storage/src/database.rs +git commit -m "$(cat <<'EOF' +feat(gamification): flag cascade-completed parents as auto_completed + +complete_subtask_and_check_parent now sets auto_completed = 1 on the +parent when it closes via the cascade. The subtask UPDATE itself +remains at the default 0, so explicit user taps still count toward +the Phase 8 daily total. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 3: `uncomplete_task` clears `auto_completed` + +**Files:** +- Modify: `crates/storage/src/database.rs:484` (the first UPDATE in `uncomplete_task`) and `:504` (the reopen-parent UPDATE). +- Test: `crates/storage/src/database.rs` test block. + +- [ ] **Step 1: Write the failing test** + +Append to the test block: + +```rust +#[tokio::test] +async fn uncomplete_clears_auto_completed_on_parent() { + let pool = test_pool().await; + + insert_task( + &pool, "parent", "Parent task", "inbox", None, None, None, None, + ).await.unwrap(); + insert_subtask(&pool, "s1", "Only step", "parent").await.unwrap(); + + // Cascade closes the parent with auto_completed = 1. + complete_subtask_and_check_parent(&pool, "s1").await.unwrap(); + + // Uncompleting the subtask reopens the parent (existing invariant) + // and must also clear auto_completed on the parent so a later + // manual re-completion is counted cleanly. + uncomplete_task(&pool, "s1").await.unwrap(); + + let parent_done: i64 = sqlx::query_scalar( + "SELECT done FROM tasks WHERE id = 'parent'", + ) + .fetch_one(&pool).await.unwrap(); + let parent_auto: i64 = sqlx::query_scalar( + "SELECT auto_completed FROM tasks WHERE id = 'parent'", + ) + .fetch_one(&pool).await.unwrap(); + + assert_eq!(parent_done, 0, "parent reopens when child is uncompleted"); + assert_eq!(parent_auto, 0, "auto_completed must clear on reopen"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p kon-storage uncomplete_clears_auto_completed -- --nocapture` +Expected: FAIL on the `parent_auto` assertion (still 1). + +- [ ] **Step 3: Modify both UPDATE statements in `uncomplete_task`** + +Change the first UPDATE (~line 484): + +**Before:** +```rust +sqlx::query("UPDATE tasks SET done = 0, done_at = NULL WHERE id = ?") + .bind(id) +``` + +**After:** +```rust +sqlx::query( + "UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 WHERE id = ?", +) + .bind(id) +``` + +Change the parent-reopen UPDATE (~line 504): + +**Before:** +```rust +sqlx::query("UPDATE tasks SET done = 0, done_at = NULL WHERE id = ? AND done = 1") + .bind(&pid) +``` + +**After:** +```rust +sqlx::query( + "UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 \ + WHERE id = ? AND done = 1", +) + .bind(&pid) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p kon-storage uncomplete_clears_auto_completed -- --nocapture` +Expected: PASS. + +- [ ] **Step 5: Run the full storage test suite** + +Run: `cargo test -p kon-storage` +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/storage/src/database.rs +git commit -m "$(cat <<'EOF' +feat(gamification): clear auto_completed on uncomplete + +uncomplete_task now clears auto_completed alongside done / done_at on +both the target row and the cascaded-parent reopen. Keeps the flag +accurate so a later re-completion via a different path is counted +correctly by the Phase 8 daily-count query. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 4: `DailyCompletionCount` struct + `list_recent_completions` function + +**Files:** +- Modify: `crates/storage/src/database.rs` (add struct + function; export from `lib.rs`). +- Modify: `crates/storage/src/lib.rs` (re-export). +- Test: `crates/storage/src/database.rs` test block. + +- [ ] **Step 1: Write the failing tests** + +Append to the test block: + +```rust +#[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 (11:00 and 13:00) are safely either side of midnight + // for any local timezone that matters for this project (UK BST / + // GMT are UTC-ish; the invariant holds anywhere between UTC-12 + // and UTC+14). + let pool = test_pool().await; + + // Seed a task that we'll manipulate the 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 "yesterday" locally + // (2 days ago at noon UTC), and row "b" at "today" (now). + sqlx::query( + "UPDATE tasks SET done = 1, done_at = datetime('now', '-2 days', '+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" is the 5th entry (0-indexed + // from end: -1 today, -2 yesterday, -3 two days ago). Check both + // positions hold exactly one completion. + 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:?}"); + + // No other day has any completion. + let total: u32 = series.iter().map(|d| d.count).sum(); + assert_eq!(total, 2, "exactly two completions across the window"); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p kon-storage list_recent_completions -- --nocapture` +Expected: FAIL — function not defined. + +- [ ] **Step 3: Add the struct and function** + +In `crates/storage/src/database.rs`, just before the `// --- Implementation intentions ---` divider (~line 527), add: + +```rust +// --- 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 / huge series. + let days = days.clamp(1, 365); + + // Pull the grouped counts from SQLite. We don't generate the spine + // in SQL — easier to left-join in Rust and keep the query simple. + #[derive(sqlx::FromRow)] + struct Row { + day: String, + count: i64, + } + + let rows: Vec = 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(|r| (r.day, r.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) +} +``` + +In `crates/storage/src/lib.rs`, add the re-export alongside the existing `pub use database::...` line(s): + +```rust +pub use database::{DailyCompletionCount, list_recent_completions}; +``` + +(Merge into the existing `pub use database::{...}` group if one exists; keep alphabetical if that's the convention.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p kon-storage list_recent_completions -- --nocapture` +Expected: all 4 new tests PASS. + +- [ ] **Step 5: Run the full storage test suite** + +Run: `cargo test -p kon-storage` +Expected: all tests pass. + +- [ ] **Step 6: Verify clippy + fmt** + +Run: `cargo clippy -p kon-storage -- -D warnings && cargo fmt --check` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/storage/src/database.rs crates/storage/src/lib.rs +git commit -m "$(cat <<'EOF' +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) +EOF +)" +``` + +--- + +### Task 5: `list_recent_completions_cmd` Tauri command + +**Files:** +- Modify: `src-tauri/src/commands/tasks.rs` (add command at end of file). +- Modify: `src-tauri/src/lib.rs` (register in `invoke_handler!`). + +- [ ] **Step 1: Add the import at the top of `src-tauri/src/commands/tasks.rs`** + +Update the existing `use kon_storage::{...}` block (currently starting at line 10) to include the new symbols. Add `DailyCompletionCount, list_recent_completions as db_list_recent_completions` alongside the existing entries. + +- [ ] **Step 2: Add the command at the end of `src-tauri/src/commands/tasks.rs`** + +```rust +/// Phase 8: daily completion counts for the Tasks-page badge and the +/// 7-day momentum sparkline. Returns a fixed-length oldest-first +/// series; empty days are explicit zeros. +#[tauri::command] +pub async fn list_recent_completions_cmd( + state: tauri::State<'_, AppState>, + days: u32, +) -> Result, String> { + db_list_recent_completions(&state.db, days) + .await + .map_err(|e| e.to_string()) +} +``` + +- [ ] **Step 3: Register the command** + +In `src-tauri/src/lib.rs`, inside the `invoke_handler!` block, below the existing `commands::tasks::complete_subtask_cmd` line (~line 319), add: + +```rust + commands::tasks::list_recent_completions_cmd, +``` + +- [ ] **Step 4: Compile** + +Run: `cargo build -p kon` +Expected: clean build. + +- [ ] **Step 5: Verify clippy + fmt** + +Run: `cargo clippy -p kon -- -D warnings && cargo fmt --check` +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/src/commands/tasks.rs src-tauri/src/lib.rs +git commit -m "$(cat <<'EOF' +feat(gamification): list_recent_completions_cmd Tauri wrapper + +Thin wrapper over kon_storage::list_recent_completions, parameterised +by day count. Serialises to camelCase JSON (day, count). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 6: Frontend types + settings field + +**Files:** +- Modify: `src/lib/types/app.ts` (add `DailyCompletionCount` type; add `showMomentumSparkline` to the settings type). +- Modify: `src/lib/stores/page.svelte.ts` (hydrate / default / persist the new setting alongside existing ones). + +- [ ] **Step 1: Open `src/lib/types/app.ts` and locate the settings type** + +Find the type declaration that shapes `settings` in `page.svelte.ts` (look for existing boolean fields like `matchMyEnergy` or `ritualsMorning`). Read the surrounding fields to match the conventions (camelCase, default handling). + +- [ ] **Step 2: Add the new type + field** + +Add near existing task-related types: + +```ts +export interface DailyCompletionCount { + day: string; // "YYYY-MM-DD" local + count: number; +} +``` + +Add to the settings interface: + +```ts + /** Phase 8. Controls the 7-day momentum sparkline only; the + * "N today" badge is always on. Default true. */ + showMomentumSparkline: boolean; +``` + +- [ ] **Step 3: Hydrate + default + persist** + +In `src/lib/stores/page.svelte.ts`, locate the block that reads settings from storage (grep for `matchMyEnergy` for a reference field) and add the corresponding default/read/write path for `showMomentumSparkline`. Default: `true`. + +- [ ] **Step 4: Type-check** + +Run: `npm run check` +Expected: zero errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/types/app.ts src/lib/stores/page.svelte.ts +git commit -m "$(cat <<'EOF' +feat(gamification): DailyCompletionCount type + showMomentumSparkline setting + +Adds the Phase 8 frontend types. New setting defaults to true (sparkline +on for everyone on upgrade) and persists via the existing settings path. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 7: `completionStats` store + +**Files:** +- Create: `src/lib/stores/completionStats.svelte.ts`. + +- [ ] **Step 1: Create the store** + +```ts +// Phase 8 store for forgiving gamification. Owns the last-7-days series +// of completion counts rendered by the Tasks-page badge + sparkline. +// +// Refresh triggers: +// - module load (first time) +// - kon:task-completed (from page.svelte.ts completeTask) +// - kon:step-completed (from MicroSteps.svelte) +// - kon:task-uncompleted (new event — emitted by uncompleteTask) +// - kon:task-deleted (new event — emitted by deleteTask) +// - window focus (for day rollover while the app stayed open +// past midnight) +// +// We deliberately avoid polling. Every path that changes a row's +// done state or deletes a row emits one of the events above. + +import { invoke } from "@tauri-apps/api/core"; +import type { DailyCompletionCount } from "$lib/types/app"; + +function hasTauriRuntime(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +const DAYS = 7; + +export const recentCompletions = $state([]); + +// Reactive derived value — Svelte 5 template consumers read it as a +// plain property (no parens) and it recomputes when recentCompletions +// changes. +export const todayCount = $derived(recentCompletions.at(-1)?.count ?? 0); + +export async function refresh(): Promise { + if (!hasTauriRuntime()) return; + try { + const series = await invoke( + "list_recent_completions_cmd", + { days: DAYS }, + ); + recentCompletions.splice(0, recentCompletions.length, ...series); + } catch (err) { + // Don't toast — this is a background read. Log only. + console.error("completionStats.refresh failed", err); + } +} + +if (typeof window !== "undefined") { + const handler = () => { + refresh().catch(() => {}); + }; + window.addEventListener("kon:task-completed", handler); + window.addEventListener("kon:step-completed", handler); + window.addEventListener("kon:task-uncompleted", handler); + window.addEventListener("kon:task-deleted", handler); + window.addEventListener("focus", handler); + + if (hasTauriRuntime()) { + refresh().catch(() => {}); + } +} +``` + +- [ ] **Step 2: Type-check** + +Run: `npm run check` +Expected: zero errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/stores/completionStats.svelte.ts +git commit -m "$(cat <<'EOF' +feat(gamification): completionStats store + +Owns the last-7-days completion series. Refreshes on task-completed / +step-completed / task-uncompleted / task-deleted window events and on +window focus (for day rollover). Derives todayCount from the newest +entry. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 8: `CompletionSparkline` component + +**Files:** +- Create: `src/lib/components/CompletionSparkline.svelte`. + +- [ ] **Step 1: Create the component** + +```svelte + + +{#if hasAnyCompletion} + + {#each data as d, i} + {@const x = i * (barWidth + BAR_GAP)} + {@const proportion = d.count / maxCount} + {@const barHeight = Math.max(1, Math.round(proportion * height))} + {@const y = height - barHeight} + + {/each} + +{/if} +``` + +- [ ] **Step 2: Type-check** + +Run: `npm run check` +Expected: zero errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/components/CompletionSparkline.svelte +git commit -m "$(cat <<'EOF' +feat(gamification): CompletionSparkline component + +Tiny inline SVG. Seven bars, zero-days render as 1 px baseline stubs. +fill=currentColor so the parent's text colour (tertiary ink on the +Tasks header) drives it. Self-hides if all 7 days are zero. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 9: Wire badge + sparkline into `TasksPage.svelte` header + +**Files:** +- Modify: `src/lib/pages/TasksPage.svelte` (the header block around line 283-289). + +- [ ] **Step 1: Add imports** + +Near the existing imports at the top of the `