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>
9.5 KiB
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 |
|
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:
- Today's completion count as a soft-edged badge ("3 today") beside the "Tasks" title.
- 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_parentcascade → 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_atis 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, setauto_completed = 1on that UPDATE. The subtask UPDATE itself leavesauto_completedat 0.complete_task: unchanged — leavesauto_completedat 0.uncomplete_task: the UPDATE already clearsdone = 0, done_at = NULL; also clearauto_completed = 0to keep the row clean if completed again via a different path.- New function
list_recent_completions(pool, days)returningVec<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 buttext-text-tertiaryso it reads as subordinate. Hidden whentodayCount === 0. - Sparkline is a ~80×16 px inline SVG of 7
<rect>bars. Zero-days render as a 1 px baseline stub. Sametext-text-tertiaryink as the badge (SVGfill="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_completionsreturns exactlydaysentries in date order.- Zero-days are present as
{day, count: 0}, not absent. auto_completed = 1rows are excluded.- Manual subtask completion is counted.
uncomplete_taskremoves the row from the count.- Day boundary: insert
done_atvalues at23:59:59 UTCon day N-1 and00:00:01 UTCon 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 = 0on pre-existing rows verified.
Frontend (npm run check + vitest)
completionStatsstore refreshes onkon:task-completedevent.todayCountderives 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_atis alwaysnow).
File map
crates/storage/src/migrations.rs— migration v13 block.crates/storage/src/database.rs—complete_subtask_and_check_parentflag set;uncomplete_taskflag clear; newlist_recent_completionsfn +DailyCompletionCountstruct.src-tauri/src/commands/...(whichever file holds task commands) —list_recent_completions_cmdTauri 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 (keepsTasksPage.sveltefocused).src/lib/types/app.ts—DailyCompletionCounttype;showMomentumSparklineadded to settings type.src/lib/pages/SettingsPage.svelte— toggle forshowMomentumSparkline.
Effort estimate
Half day, matching the roadmap estimate.