---
name: Phase 8 — Forgiving gamification (design)
description: Design spec for Lumotia Phase 8. Surfaces today's completion count and a 7-day momentum sparkline on the Tasks header. No streaks, no grace days, no loss language.
type: spec
tags: [spec, phase-8, lumotia, tasks, gamification]
created: 2026/04/24
status: approved
phase: 8
roadmap: docs/roadmap/2026-04-23-lumotia-feature-complete-roadmap.md
author: Wren (CORBEL's resident agent) on behalf of Jake Sames
---

# Phase 8 — Forgiving gamification

## Summary

Add two purely additive signals to the Tasks page header:
1. **Today's completion count** as a soft-edged badge ("3 today") beside the "Tasks" title.
2. **7-day momentum sparkline** — a tiny inline 7-bar chart on the same line.

No streaks. No chains. No grace days. No loss language. Empty days render as baseline stubs (never gaps). Zero social comparison.

This is the final feature phase before polish (Phase 9) and release prep (Phase 10).

## Counting semantics

A "completion today" is **any task row whose `done_at` falls on the local calendar day and which was completed by explicit user action**. Specifically:

- Top-level tasks explicitly completed → **count**.
- Subtasks explicitly completed (their own checkbox clicked) → **count**.
- Parent tasks auto-completed via the `complete_subtask_and_check_parent` cascade → **do not count**. The user already got credit for the subtask they just ticked; the parent closing automatically must not double-count.
- Uncompleting a task removes it from the count immediately (its row stops matching the query because `done_at` is nulled).

Day boundary is **local-time midnight-to-midnight**. `done_at` is stored as UTC; conversion uses SQLite's `DATE(done_at, 'localtime')`.

## Architecture

### Data-model change — migration v13

Add a column to distinguish cascade-completed parents from explicit completions:

```sql
ALTER TABLE tasks ADD COLUMN auto_completed INTEGER NOT NULL DEFAULT 0;

CREATE INDEX idx_tasks_done_at_auto_completed
    ON tasks(done_at, auto_completed)
    WHERE done_at IS NOT NULL;
```

Partial index on `done_at IS NOT NULL` keeps the index small — only completed rows occupy it. Forward-only migration: existing completed rows default to `auto_completed = 0` (they will count). We have no way to retro-detect pre-migration cascades; accept this one-time inaccuracy.

### Rust changes

**`crates/storage/src/database.rs`:**
- `complete_subtask_and_check_parent`: when the cascade UPDATE closes the parent, set `auto_completed = 1` on that UPDATE. The subtask UPDATE itself leaves `auto_completed` at 0.
- `complete_task`: unchanged — leaves `auto_completed` at 0.
- `uncomplete_task`: the UPDATE already clears `done = 0, done_at = NULL`; also clear `auto_completed = 0` to keep the row clean if completed again via a different path.
- New function `list_recent_completions(pool, days)` returning `Vec<DailyCompletionCount>`:

```rust
pub struct DailyCompletionCount {
    pub day: String,   // "YYYY-MM-DD" local
    pub count: u32,
}

pub async fn list_recent_completions(
    pool: &SqlitePool,
    days: u32,
) -> Result<Vec<DailyCompletionCount>>;
```

Query groups matching rows by local-day and then left-joins against a generated N-day spine (computed in Rust) so empty days are explicit zeros, not missing entries. Result is ordered oldest → newest.

**Tauri command:** `list_recent_completions_cmd(days: u32)` in `src-tauri`, wired via the standard invoke pattern used by existing task commands.

### Frontend changes

**New store `src/lib/stores/completionStats.svelte.ts`:**

```typescript
// Exposes:
//   recentCompletions: DailyCompletionCount[]  — always length = days
//   todayCount: number                          — derived from last entry
//   refresh(): Promise<void>                    — invokes the Rust command

// Refreshes on:
//   - initial load (called from the same init path that runs loadTasks)
//   - after every task-mutation helper in page.svelte.ts
//     (completeTask / uncompleteTask / deleteTask / completeSubtask /
//     uncompleteSubtask / deleteSubtask) — call sites add a
//     completionStats.refresh() alongside the existing loadTasks() refresh
//   - window focus (for day rollover while the app stayed open overnight)
```

**Why not hook off `lumotia:task-completed` alone:** that event only fires on completion, not on uncomplete or delete, and relying on it would leave the badge stale after either of those paths. Refreshing at the mutation-helper level captures every path that can change today's count.

**`src/lib/pages/TasksPage.svelte` header:**
Stacks to:

```
Tasks · 3 today          ▁▂▅▃▁▆▄
Add tasks manually…
```

- Badge is an inline `<span>` after the "Tasks" title, matching title sizing but `text-text-tertiary` so it reads as subordinate. Hidden when `todayCount === 0`.
- Sparkline is a ~80×16 px inline SVG of 7 `<rect>` bars. Zero-days render as a 1 px baseline stub. Same `text-text-tertiary` ink as the badge (SVG `fill="currentColor"` so the parent text colour drives it). Hidden when **either** the user has toggled it off **or** the full 7-day window is all zeros.
- A single wrapper element around just the badge + sparkline carries `aria-live="polite"` (scope deliberately small so screen readers announce only the count change, not the rest of the header).

**Accessibility labels:**
- Badge: `aria-label="3 tasks completed today"` (singular/plural aware).
- Sparkline: `aria-label="Tasks completed over the last 7 days: 0, 1, 3, 2, 0, 4, 3"` with values from the current data.

### Settings

New preference: `showMomentumSparkline: boolean`, default `true`, persisted through the existing `saveSettings` path.

Settings page — in the same visual cluster as the existing energy / match-my-energy toggles — one toggle:

- Label: **"Show momentum sparkline"**
- Helper: **"A tiny chart of the last 7 days' completion counts, shown on the Tasks header. Never counts against you."**

Only the sparkline respects the toggle. The badge is always on.

## Invariants

- Day boundary: local time.
- Subtasks count on explicit completion; auto-cascade parents do not.
- Uncomplete removes from count.
- Sparkline is 7 bars always (even if all zero; the 7-day-all-zero case hides the whole sparkline rather than showing a flat row — different from a single intra-week zero which renders as a 1 px stub).
- Brand-new install: no badge (count=0), no sparkline (all 7 days=0).
- Zero loss language anywhere. No "you were away", no "streak broken", no "4 days ago".

## Acceptance (from roadmap)

- Complete 3 tasks today → header shows "3 today".
- Open the app after 4 days off → no "you were away" framing; header reads today's count only; sparkline renders flat zero-stubs for the 4 away days, real bars for the other 3.
- Cascade-complete a parent via its last subtask → badge increments by 1 (the subtask), not 2.
- Uncomplete a task that was completed today → badge decrements by 1.
- Toggle "Show momentum sparkline" off → sparkline disappears; badge stays.

## Testing

### Rust (`cargo test` in `crates/storage`)
- `list_recent_completions` returns exactly `days` entries in date order.
- Zero-days are present as `{day, count: 0}`, not absent.
- `auto_completed = 1` rows are excluded.
- Manual subtask completion is counted.
- `uncomplete_task` removes the row from the count.
- Day boundary: insert `done_at` values at `23:59:59 UTC` on day N-1 and `00:00:01 UTC` on day N; query result correctly places each in the local day.
- Migration v13: applies cleanly against a DB populated at v12; default of `auto_completed = 0` on pre-existing rows verified.

### Frontend (`npm run check` + vitest)
- `completionStats` store refreshes on `lumotia:task-completed` event.
- `todayCount` derives from the last entry.
- Sparkline renders exactly 7 bars for a populated fixture.
- Sparkline hidden when `showMomentumSparkline === false`.
- Badge hidden when `todayCount === 0`.
- Brand-new install (empty fixture): both elements hidden.

### Manual dogfood (folds into Phase 10a QC)
- Complete 3 tasks. Header reads "3 today".
- Uncomplete one. Header reads "2 today".
- Micro-step a task, complete its last subtask. Header increments by 1 (subtask), not 2.
- Leave the app open past local midnight. On focus, badge resets to "0" (hidden) and sparkline shifts left.

## Out of scope

- Per-day tooltip on the sparkline (Phase 9).
- Motion curves / enter animations (Phase 9).
- Dark-mode colour tweaks beyond what the tertiary ink already gives us (Phase 9).
- Any analytics, telemetry, export, or CSV download (not a v0.1 concern).
- Per-list or per-bucket breakdowns (everything aggregates across all tasks).
- Future-dated completions (can't happen — `done_at` is always `now`).

## File map

- `crates/storage/src/migrations.rs` — migration v13 block.
- `crates/storage/src/database.rs` — `complete_subtask_and_check_parent` flag set; `uncomplete_task` flag clear; new `list_recent_completions` fn + `DailyCompletionCount` struct.
- `src-tauri/src/commands/...` (whichever file holds task commands) — `list_recent_completions_cmd` Tauri command.
- `src/lib/stores/completionStats.svelte.ts` — new.
- `src/lib/pages/TasksPage.svelte` — header markup for badge + sparkline; subscribe to store.
- `src/lib/components/CompletionSparkline.svelte` — new, dedicated SVG component (keeps `TasksPage.svelte` focused).
- `src/lib/types/app.ts` — `DailyCompletionCount` type; `showMomentumSparkline` added to settings type.
- `src/lib/pages/SettingsPage.svelte` — toggle for `showMomentumSparkline`.

## Effort estimate

Half day, matching the roadmap estimate.
