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>
This commit is contained in:
@@ -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<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 `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.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.
|
||||
Reference in New Issue
Block a user