Replace all instances of the legacy product names "Kon" and "Corbie" with "Magnotia" across user-facing copy, code identifiers, package names, bundle ids, file paths, and documentation. Preserves the unrelated "konsole" (KDE terminal) reference and the parent CORBEL company name. - Renames 10 Rust crates (kon-* → magnotia-*) and the tauri binary - Updates package.json, tauri.conf.json (productName + identifier) - Renames CSS classes (kon-rh-* → magnotia-rh-*) and animations - Renames brand and roadmap docs - Regenerates Cargo.lock and package-lock.json Verified: svelte-check passes; pure-rust crates compile under new names.
1270 lines
42 KiB
Markdown
1270 lines
42 KiB
Markdown
# 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 magnotia-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 magnotia-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 magnotia-storage migration_v13 -- --nocapture`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Run the full storage test suite to confirm no regression**
|
|
|
|
Run: `cargo test -p magnotia-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 magnotia-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 magnotia-storage cascade_sets_auto_completed -- --nocapture`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Run the full storage test suite**
|
|
|
|
Run: `cargo test -p magnotia-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 magnotia-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 magnotia-storage uncomplete_clears_auto_completed -- --nocapture`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Run the full storage test suite**
|
|
|
|
Run: `cargo test -p magnotia-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 magnotia-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 magnotia-storage list_recent_completions -- --nocapture`
|
|
Expected: all 4 new tests PASS.
|
|
|
|
- [ ] **Step 5: Run the full storage test suite**
|
|
|
|
Run: `cargo test -p magnotia-storage`
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 6: Verify clippy + fmt**
|
|
|
|
Run: `cargo clippy -p magnotia-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 magnotia_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 magnotia`
|
|
Expected: clean build.
|
|
|
|
- [ ] **Step 5: Verify clippy + fmt**
|
|
|
|
Run: `cargo clippy -p magnotia -- -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 magnotia_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)
|
|
// - magnotia:task-completed (from page.svelte.ts completeTask)
|
|
// - magnotia:step-completed (from MicroSteps.svelte)
|
|
// - magnotia:task-uncompleted (new event — emitted by uncompleteTask)
|
|
// - magnotia: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("magnotia:task-completed", handler);
|
|
window.addEventListener("magnotia:step-completed", handler);
|
|
window.addEventListener("magnotia:task-uncompleted", handler);
|
|
window.addEventListener("magnotia: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 `magnotia:task-uncompleted` + `magnotia:task-deleted`
|
|
|
|
**Files:**
|
|
- Modify: `src/lib/stores/page.svelte.ts` — `uncompleteTask` (line 470-479) and `deleteTask` (line 438-451).
|
|
|
|
- [ ] **Step 1: Emit `magnotia: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("magnotia:task-uncompleted", { detail: { id } }));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Emit `magnotia: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("magnotia: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 magnotia: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-magnotia-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-magnotia-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**.
|