# 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 lumotia-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<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");
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cargo test -p lumotia-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 lumotia-storage migration_v13 -- --nocapture`
Expected: PASS.

- [ ] **Step 5: Run the full storage test suite to confirm no regression**

Run: `cargo test -p lumotia-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) <noreply@anthropic.com>
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 lumotia-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| MagnotiaError::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| MagnotiaError::StorageError(format!("Auto-complete parent failed: {e}")))?;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cargo test -p lumotia-storage cascade_sets_auto_completed -- --nocapture`
Expected: PASS.

- [ ] **Step 5: Run the full storage test suite**

Run: `cargo test -p lumotia-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) <noreply@anthropic.com>
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 lumotia-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 lumotia-storage uncomplete_clears_auto_completed -- --nocapture`
Expected: PASS.

- [ ] **Step 5: Run the full storage test suite**

Run: `cargo test -p lumotia-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) <noreply@anthropic.com>
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 lumotia-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<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 / 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<Row> = 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| MagnotiaError::StorageError(format!("List recent completions failed: {e}")))?;

    let lookup: std::collections::HashMap<String, u32> = 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| MagnotiaError::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| MagnotiaError::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 lumotia-storage list_recent_completions -- --nocapture`
Expected: all 4 new tests PASS.

- [ ] **Step 5: Run the full storage test suite**

Run: `cargo test -p lumotia-storage`
Expected: all tests pass.

- [ ] **Step 6: Verify clippy + fmt**

Run: `cargo clippy -p lumotia-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) <noreply@anthropic.com>
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 lumotia_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<Vec<DailyCompletionCount>, 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 lumotia`
Expected: clean build.

- [ ] **Step 5: Verify clippy + fmt**

Run: `cargo clippy -p lumotia -- -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 lumotia_storage::list_recent_completions, parameterised
by day count. Serialises to camelCase JSON (day, count).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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)
//  - lumotia:task-completed  (from page.svelte.ts completeTask)
//  - lumotia:step-completed  (from MicroSteps.svelte)
//  - lumotia:task-uncompleted (new event — emitted by uncompleteTask)
//  - lumotia: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<DailyCompletionCount[]>([]);

// 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<void> {
  if (!hasTauriRuntime()) return;
  try {
    const series = await invoke<DailyCompletionCount[]>(
      "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("lumotia:task-completed", handler);
  window.addEventListener("lumotia:step-completed", handler);
  window.addEventListener("lumotia:task-uncompleted", handler);
  window.addEventListener("lumotia: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) <noreply@anthropic.com>
EOF
)"
```

---

### Task 8: `CompletionSparkline` component

**Files:**
- Create: `src/lib/components/CompletionSparkline.svelte`.

- [ ] **Step 1: Create the component**

```svelte
<script lang="ts">
  // Phase 8. Tiny inline SVG sparkline: 7 bars, oldest → newest.
  // Zero-days render as a 1 px baseline stub (never a gap, never zero
  // height). The component only renders itself when it has data AND
  // some non-zero day — caller still guards on the settings toggle.
  import type { DailyCompletionCount } from "$lib/types/app";

  interface Props {
    data: DailyCompletionCount[];
    /** Total width in px. Default 80. */
    width?: number;
    /** Total height in px. Default 16. */
    height?: number;
  }

  let { data, width = 80, height = 16 }: Props = $props();

  const BAR_GAP = 2;

  let maxCount = $derived(Math.max(1, ...data.map((d) => d.count)));
  let barWidth = $derived(
    (width - BAR_GAP * Math.max(0, data.length - 1)) / Math.max(1, data.length),
  );

  let ariaLabel = $derived.by(() => {
    if (data.length === 0) return "";
    const nums = data.map((d) => d.count).join(", ");
    return `Tasks completed over the last ${data.length} days: ${nums}`;
  });

  let hasAnyCompletion = $derived(data.some((d) => d.count > 0));
</script>

{#if hasAnyCompletion}
  <svg
    {width}
    {height}
    viewBox={`0 0 ${width} ${height}`}
    role="img"
    aria-label={ariaLabel}
    class="text-text-tertiary"
  >
    {#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}
      <rect
        {x}
        {y}
        width={barWidth}
        height={barHeight}
        fill="currentColor"
        opacity={d.count === 0 ? 0.35 : 0.85}
        rx="1"
      />
    {/each}
  </svg>
{/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) <noreply@anthropic.com>
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 `<script lang="ts">` block, add:

```ts
import { recentCompletions, todayCount } from "$lib/stores/completionStats.svelte";
import CompletionSparkline from "$lib/components/CompletionSparkline.svelte";
```

- [ ] **Step 2: Replace the header title cluster**

Current markup (around line 285-289):

```svelte
  <div class="flex items-center px-7 pt-6 pb-2">
    <div>
      <h2 class="text-xl font-display text-text italic">Tasks</h2>
      <p class="text-[11px] text-text-tertiary mt-1">Add tasks manually. Automatic extraction from your transcripts is coming.</p>
    </div>
```

Replace with:

```svelte
  <div class="flex items-center px-7 pt-6 pb-2">
    <div>
      <div class="flex items-baseline gap-3">
        <h2 class="text-xl font-display text-text italic">Tasks</h2>

        <!-- Phase 8 badge + sparkline. aria-live scoped to just this
             wrapper so screen readers announce completions without
             re-reading the rest of the header. -->
        <div
          class="flex items-center gap-2"
          aria-live="polite"
        >
          {#if todayCount > 0}
            <span
              class="text-[11px] text-text-tertiary"
              aria-label={`${todayCount} ${todayCount === 1 ? "task" : "tasks"} completed today`}
            >
              {todayCount} today
            </span>
          {/if}

          {#if settings.showMomentumSparkline}
            <CompletionSparkline data={recentCompletions} />
          {/if}
        </div>
      </div>

      <p class="text-[11px] text-text-tertiary mt-1">Add tasks manually. Automatic extraction from your transcripts is coming.</p>
    </div>
```

- [ ] **Step 3: Type-check**

Run: `npm run check`
Expected: zero errors.

- [ ] **Step 4: Visual smoke test via vite dev**

Run: `npm run dev:frontend` (in background if needed).
Open the app, navigate to Tasks.
- If there are no completed tasks today and no completions in the last 7 days: neither the badge nor the sparkline should render.
- Complete a task: badge reads "1 today", sparkline appears with today's bar filled.

If verification in the running app isn't possible in this environment, state so explicitly in the execution report. Do not claim success based on compilation alone.

- [ ] **Step 5: Commit**

```bash
git add src/lib/pages/TasksPage.svelte
git commit -m "$(cat <<'EOF'
feat(gamification): today count + sparkline on Tasks header

Badge renders when today's count > 0. Sparkline renders when the
setting is enabled and any of the last 7 days has a completion.
Wrapped in a narrow aria-live region so increments announce without
re-reading the rest of the header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 10: Dispatch `lumotia:task-uncompleted` + `lumotia:task-deleted`

**Files:**
- Modify: `src/lib/stores/page.svelte.ts` — `uncompleteTask` (line 470-479) and `deleteTask` (line 438-451).

- [ ] **Step 1: Emit `lumotia:task-uncompleted` from `uncompleteTask`**

Current body:
```ts
export async function uncompleteTask(id: string) {
  if (!hasTauriRuntime()) return;
  try {
    await invoke("uncomplete_task_cmd", { id });
    applyLocalTaskUpdate(id, { done: false, doneAt: null });
  } catch (err) {
    toasts.error("Couldn't uncomplete task", errorMessage(err));
  }
}
```

Replace the try-block body with:

```ts
    await invoke("uncomplete_task_cmd", { id });
    applyLocalTaskUpdate(id, { done: false, doneAt: null });
    if (typeof window !== "undefined") {
      window.dispatchEvent(new CustomEvent("lumotia:task-uncompleted", { detail: { id } }));
    }
```

- [ ] **Step 2: Emit `lumotia:task-deleted` from `deleteTask`**

Current body:
```ts
export async function deleteTask(id: string) {
  if (!hasTauriRuntime()) return;
  try {
    await invoke("delete_task_cmd", { id });
    const idx = tasks.findIndex((task) => task.id === id);
    if (idx >= 0) {
      tasks.splice(idx, 1);
      broadcastTasks();
    }
  } catch (err) {
    toasts.error("Couldn't delete task", errorMessage(err));
  }
}
```

Add the dispatch inside the success path, right after `broadcastTasks();`:

```ts
    if (typeof window !== "undefined") {
      window.dispatchEvent(new CustomEvent("lumotia:task-deleted", { detail: { id } }));
    }
```

- [ ] **Step 3: Type-check**

Run: `npm run check`
Expected: zero errors.

- [ ] **Step 4: Commit**

```bash
git add src/lib/stores/page.svelte.ts
git commit -m "$(cat <<'EOF'
feat(gamification): emit task-uncompleted + task-deleted events

Phase 8 completionStats store listens on these events to refresh the
daily count. Keeps the badge + sparkline accurate after un-tick / delete
paths, which don't currently fire lumotia:task-completed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 11: Settings toggle for sparkline

**Files:**
- Modify: `src/lib/pages/SettingsPage.svelte`.

- [ ] **Step 1: Locate the existing Tasks-related toggles**

`grep -n "matchMyEnergy\|ritualsMorning" src/lib/pages/SettingsPage.svelte` to find the settings cluster to sit within. Match the surrounding toggle markup style — don't invent a new pattern.

- [ ] **Step 2: Add the toggle**

Inside the appropriate section, near the energy or ritual toggles, add (match local markup conventions exactly — this sketch shows the intent):

```svelte
<!-- Phase 8: sparkline toggle. Badge is always on; only the sparkline
     respects this. Default true. -->
<div class="flex items-start justify-between gap-4 py-3">
  <div>
    <p class="text-sm text-text">Show momentum sparkline</p>
    <p class="text-[11px] text-text-tertiary mt-0.5">
      A tiny chart of the last 7 days' completion counts, shown on the Tasks header. Never counts against you.
    </p>
  </div>
  <Toggle
    bind:checked={settings.showMomentumSparkline}
    onchange={() => saveSettings()}
    ariaLabel="Show momentum sparkline"
  />
</div>
```

If `Toggle.svelte` has a different prop name for `checked` or `onchange`, use the actual names used by neighbour toggles in the same file. Do not invent.

- [ ] **Step 3: Type-check**

Run: `npm run check`
Expected: zero errors.

- [ ] **Step 4: Smoke test**

In `npm run dev:frontend`, open Settings → toggle off → Tasks page shows badge but no sparkline. Toggle on → sparkline returns. Settings persist across app restart.

- [ ] **Step 5: Commit**

```bash
git add src/lib/pages/SettingsPage.svelte
git commit -m "$(cat <<'EOF'
feat(gamification): settings toggle for momentum sparkline

Default on. Controls only the sparkline; the "N today" badge is
unconditional. Copy kept in the zero-loss register: "Never counts
against you."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 12: Full verification pass

- [ ] **Step 1: Rust format check**

Run: `cargo fmt --check`
Expected: no output (clean).

- [ ] **Step 2: Rust clippy**

Run: `cargo clippy --all-targets -- -D warnings`
Expected: clean, zero warnings.

- [ ] **Step 3: Full Rust test suite**

Run: `cargo test`
Expected: all tests pass. Record the count in the handover.

- [ ] **Step 4: Frontend type-check**

Run: `npm run check`
Expected: 0 errors, 0 warnings.

- [ ] **Step 5: Frontend build**

Run: `npm run build`
Expected: clean production build.

- [ ] **Step 6: Manual dogfood script**

Drive the app through the Phase 8 acceptance list:

1. Fresh state — no completions today, none in last 7 days: **header shows only "Tasks" title; no badge, no sparkline**.
2. Complete one top-level task → **badge "1 today"; sparkline appears with today's bar filled**.
3. Complete two more → **badge "3 today"**.
4. Uncomplete one → **badge "2 today"**.
5. Micro-step a task; complete its final subtask so the cascade auto-closes the parent → **badge increments by 1 (the subtask), not 2**.
6. Settings → toggle off sparkline → **sparkline disappears, badge remains**.
7. Toggle on → **sparkline returns**.

Record any deviations in the handover. Do not mark this step complete on compile-only evidence.

- [ ] **Step 7: No commit for this task (verification-only)**

---

### Task 13: Update roadmap and handover

**Files:**
- Modify: `docs/roadmap/2026-04-23-lumotia-feature-complete-roadmap.md` (Phase 8 section — mark shipped, fold the revised-dropped-grace-days line into past tense).
- Modify: `HANDOVER.md` (end-of-session summary).

- [ ] **Step 1: Update roadmap Phase 8 status**

At the top of the Phase 8 section, add a shipped marker in the same style other shipped phases use:

```markdown
## Phase 8 — Forgiving gamification — **REVISED 2026/04/23** — **SHIPPED 2026/04/24**
```

(Match whatever marker convention Phases 1-7 use; if there is none yet, keep the revision tag and add a short trailing line under "Acceptance" noting shipped state.)

- [ ] **Step 2: Update `HANDOVER.md`**

Replace the session summary with today's state:

- Phase 8 shipped. Migration v13 added. `list_recent_completions_cmd` live.
- cargo test count, clippy clean, fmt clean, npm run check clean, npm run build clean.
- Open for Phase 9: polish debt (file-system .md save dialog, bulk export, LLM content tags, settings UX pass, visual polish, accessibility sweep).
- Also note: roadmap still lists Cargo.lock as open; Jake's hardening pass closed that. Small doc update owed.

- [ ] **Step 3: Commit**

```bash
git add docs/roadmap/2026-04-23-lumotia-feature-complete-roadmap.md HANDOVER.md
git commit -m "$(cat <<'EOF'
docs(phase8): mark Phase 8 shipped + refresh handover

Phase 8 acceptance list verified in dogfood walkthrough. Migration v13
applied. HANDOVER points to Phase 9 (polish debt) as the natural next
session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Completion criteria

All tasks above checked off. `cargo test` green, `cargo clippy` clean, `cargo fmt --check` clean, `npm run check` clean, `npm run build` clean. Manual dogfood walkthrough passes all seven steps in Task 12 Step 6. Roadmap + HANDOVER updated.

Natural next session: **Phase 9 — polish debt**.
