From 2cc0697de97b2087466bfa3091703383a6d48248 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 24 Apr 2026 19:53:52 +0100 Subject: [PATCH] docs(phase8): design spec for forgiving gamification Covers today's completion count badge + 7-day momentum sparkline on the Tasks header. Chose to exclude auto-cascade parents from the count via a new auto_completed column (migration v13), to respect the zero-loss framing. Sparkline is a new settings toggle, default on. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...24-phase8-forgiving-gamification-design.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-24-phase8-forgiving-gamification-design.md diff --git a/docs/superpowers/specs/2026-04-24-phase8-forgiving-gamification-design.md b/docs/superpowers/specs/2026-04-24-phase8-forgiving-gamification-design.md new file mode 100644 index 0000000..09cf074 --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-phase8-forgiving-gamification-design.md @@ -0,0 +1,188 @@ +--- +name: Phase 8 — Forgiving gamification (design) +description: Design spec for Corbie 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, corbie, tasks, gamification] +created: 2026/04/24 +status: approved +phase: 8 +roadmap: docs/roadmap/2026-04-23-corbie-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`: + +```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>; +``` + +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 — 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 `kon: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 `` 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 `` 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 `kon: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.