Files
Lumotia/docs/superpowers/plans/2026-04-24-phase8-forgiving-gamification.md
Jake 26c7307607
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
agent: lumotia-rebrand — docs, scripts, root config, residuals
Phase 9 of the rebrand cascade. Sweep covers everything the Phase 8
frontend pass deliberately skipped: docs/, root markdown, scripts,
Cargo.toml descriptions, code comments that survived earlier
word-boundary sed, plus a handful of identifiers caught on the final
verify pass.

transcription-app changes:
- README.md, HANDOVER.md, KNOWN-ISSUES.md, run.sh — magnotia/Magnotia
  -> lumotia/Lumotia.
- docs/ — sweep across all subdirs except docs/handovers/ (preserved
  as immutable audit trail). Includes architecture-map references
  to magnotia_core::*, magnotia_storage::*, etc. now pointing at
  lumotia_*; dev-setup.md tracing output examples (lumotia_startup
  target); brief/ + superpowers/ + issues/ + whisper-ecosystem/ +
  audit/.
- Cargo.toml descriptions on 9 crates (core, audio, cloud-providers,
  hotkey, llm, mcp, plus referenced others).
- crates/core/src/{error,hardware,recommendation,paths}.rs +
  crates/audio/src/wav.rs + crates/llm/src/model_manager.rs +
  crates/cloud-providers/src/keystore.rs + crates/mcp/src/lib.rs —
  doc comments and a model-manager user-agent string.
- Caught on final pass: BroadcastChannel("magnotia_task_sync") -> ...
  ("lumotia_task_sync"); magnotia_locale i18n localStorage key
  renamed + migration shim added; CSS keyframe names
  magnotiaPulse / magnotiaBar / magnotiaFade renamed in the design-
  system kit; magnotia_viewer_item / magnotia_viewer_mode handoff
  keys renamed in HistoryPage + viewer/+page.svelte; src/assets/
  wordmark.svg text.
- src-tauri/src/lib.rs comment cleanup ("magnotia era" was sed'd
  to "lumotia era" earlier — restored).

Preserved (intentional):
- crates/core/src/paths.rs — keeps "magnotia" / "Magnotia" / ".magnotia"
  legacy detection strings in legacy_and_target_paths() so the
  migration shim can still find user data from the magnotia era.
- src/lib/stores/{page,focusTimer}.svelte.ts + src/lib/i18n/index.ts
  — migration call sites reference the legacy magnotia keys
  deliberately.
- docs/handovers/ — historical audit trail.

cargo build --workspace passes. npm run check: 0 errors / 0 warnings
(3958 files). cargo test --workspace: 339 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:38:03 +01:00

42 KiB

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


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 runnernpm 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:

#[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 ];:

(
    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
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):

#[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:

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:

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
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:

#[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:

sqlx::query("UPDATE tasks SET done = 0, done_at = NULL WHERE id = ?")
    .bind(id)

After:

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:

sqlx::query("UPDATE tasks SET done = 0, done_at = NULL WHERE id = ? AND done = 1")
    .bind(&pid)

After:

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
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:

#[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:

// --- 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):

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
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
/// 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:

            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
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:

export interface DailyCompletionCount {
  day: string;   // "YYYY-MM-DD" local
  count: number;
}

Add to the settings interface:

  /** 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
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

// 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
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

<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
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:

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):

  <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:

  <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
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.tsuncompleteTask (line 470-479) and deleteTask (line 438-451).

  • Step 1: Emit lumotia:task-uncompleted from uncompleteTask

Current body:

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:

    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:

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();:

    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
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):

<!-- 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
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:

## 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

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.