Files
Lumotia/docs/superpowers/specs/2026-04-24-phase8-forgiving-gamification-design.md
Jake 2cc0697de9 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) <noreply@anthropic.com>
2026-04-24 19:53:52 +01:00

9.5 KiB
Raw Permalink Blame History

name, description, type, tags, created, status, phase, roadmap, author
name description type tags created status phase roadmap author
Phase 8 — Forgiving gamification (design) 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. spec
spec
phase-8
corbie
tasks
gamification
2026/04/24 approved 8 docs/roadmap/2026-04-23-corbie-feature-complete-roadmap.md 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:

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

// 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 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 <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 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.rscomplete_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.tsDailyCompletionCount type; showMomentumSparkline added to settings type.
  • src/lib/pages/SettingsPage.svelte — toggle for showMomentumSparkline.

Effort estimate

Half day, matching the roadmap estimate.