# 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 = 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 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) 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) 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) 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> { // 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| MagnotiaError::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| 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) 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, 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) 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) // - 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([]); // 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("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) 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 `