summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans/2026-07-16-task-budgets-and-availability.md
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-16 17:17:58 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-16 17:17:58 +0000
commit533e8ac3aa2d0a3a1bda50a984356c9fcc40076d (patch)
tree530ec35e32a5c374b4e759d4b691fafc8ac1d14a /docs/superpowers/plans/2026-07-16-task-budgets-and-availability.md
parentbc39b8512f7451751d322a33b433f2db51ddc0ee (diff)
Add implementation plan for task budgets and availability
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQaPGQVSfmKUiHXB87qRTC
Diffstat (limited to 'docs/superpowers/plans/2026-07-16-task-budgets-and-availability.md')
-rw-r--r--docs/superpowers/plans/2026-07-16-task-budgets-and-availability.md2393
1 files changed, 2393 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-16-task-budgets-and-availability.md b/docs/superpowers/plans/2026-07-16-task-budgets-and-availability.md
new file mode 100644
index 0000000..24f1d6a
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-16-task-budgets-and-availability.md
@@ -0,0 +1,2393 @@
+# Task Budgets and Availability Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let a user mark doot-native projects/labels as "budget-tracked," give tasks time estimates, define a weekly availability template, and see a purely-informational "scheduled vs. available" flag on today's and this week's load — no auto-scheduling, no auto-reprioritization.
+
+**Architecture:** New `availability_blocks` table (manual weekly template) plus `estimated_minutes` on `native_tasks` and `budget_tracked` flags on `projects`/`labels`. A pure Go function subtracts overlapping calendar events from availability and sums estimates for tracked, due tasks — computed on read inside the existing `/api/widget` and web-timeline request paths, no new background job. Surfaced as a small indicator in the web timeline and a compact badge on the widget's TODAY header.
+
+**Tech Stack:** Go (chi router, `database/sql` + sqlite3), HTMX/Tailwind templates, Kotlin/Jetpack Glance (Android widget).
+
+## Global Constraints
+
+- doot-native tasks only — Trello cards, Google Tasks, and calendar events never participate in budget tracking.
+- Budget tracking is opt-in per project/label; untracked tasks are invisible to this feature (default experience unchanged).
+- No auto-scheduling, no auto-reprioritization — the flag is purely informational.
+- No new background job — computed on read at the same points `BuildTimeline`/`HandleWidgetGet`/`HandleTimeline` already run.
+- All schema changes go in `migrations/` as a new file; migrations run alphabetically by filename via `filepath.Glob` + `sort.Strings` (see `internal/store/sqlite.go`).
+- SQL: parameterized queries only (`?` placeholders), never string-interpolated values.
+- Validate with `go test ./...` (Go) after every task; Android tasks are validated by inspection since no Android test harness runs in this plan.
+
+---
+
+### Task 1: Migration — schema changes
+
+**Files:**
+- Create: `migrations/025_task_budgets_and_availability.sql`
+- Modify: `internal/store/sqlite_test.go:169-183` (`setupTestStoreWithNativeTasks` schema string)
+- Modify: `internal/store/native_tasks_test.go:27-67` (`newNativeTasksTestStore` schema strings)
+
+**Interfaces:**
+- Produces: table `availability_blocks(id, weekday, start_time, end_time, label)`; `native_tasks.estimated_minutes INTEGER DEFAULT 0`; `projects.budget_tracked BOOLEAN DEFAULT 0`; `labels.budget_tracked BOOLEAN DEFAULT 0`.
+
+- [ ] **Step 1: Write the migration**
+
+```sql
+-- migrations/025_task_budgets_and_availability.sql
+-- Opt-in time-budget visibility: a manual weekly availability template,
+-- per-task time estimates, and a budget-tracked flag on projects/labels so
+-- only tasks the user has opted in count against any budget calculation.
+CREATE TABLE availability_blocks (
+ id TEXT PRIMARY KEY,
+ weekday INTEGER NOT NULL, -- 0-6, Sun-Sat
+ start_time TEXT NOT NULL, -- "18:00"
+ end_time TEXT NOT NULL, -- "20:00"
+ label TEXT DEFAULT ''
+);
+
+ALTER TABLE native_tasks ADD COLUMN estimated_minutes INTEGER DEFAULT 0;
+ALTER TABLE projects ADD COLUMN budget_tracked BOOLEAN DEFAULT 0;
+ALTER TABLE labels ADD COLUMN budget_tracked BOOLEAN DEFAULT 0;
+```
+
+- [ ] **Step 2: Update the hand-rolled test schemas so store-level unit tests keep working**
+
+In `internal/store/sqlite_test.go`, `setupTestStoreWithNativeTasks` builds its own `CREATE TABLE native_tasks` (it doesn't run real migrations). This schema string is currently missing `project_id` (a pre-existing baseline bug from an earlier feature that was never backported here — `go test ./internal/store/...` currently fails two tests with "table native_tasks has no column named project_id"; confirm this yourself with that command before editing, so you're not chasing a regression you introduced). Fix that gap and add the new column in the same edit:
+
+```go
+ schema := `
+ CREATE TABLE IF NOT EXISTS native_tasks (
+ id TEXT PRIMARY KEY,
+ content TEXT NOT NULL,
+ description TEXT DEFAULT '',
+ project_name TEXT DEFAULT '',
+ project_id TEXT DEFAULT '',
+ due_date DATETIME,
+ priority INTEGER DEFAULT 1,
+ completed BOOLEAN DEFAULT 0,
+ labels TEXT DEFAULT '[]',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ recurrence_freq TEXT DEFAULT '',
+ recurrence_interval INTEGER DEFAULT 1,
+ recurrence_weekdays TEXT DEFAULT '',
+ recurrence_series_id TEXT DEFAULT '',
+ next_occurrence_override TEXT DEFAULT '',
+ estimated_minutes INTEGER DEFAULT 0
+ );
+ `
+```
+(this replaces the entire existing `schema := \`...\`` string in that function — same variable, same surrounding function, just the two added columns: `project_id` and `estimated_minutes`)
+
+In `internal/store/native_tasks_test.go`, `newNativeTasksTestStore` builds three tables by hand. Update all three:
+
+```go
+ if _, err := db.Exec(`
+ CREATE TABLE native_tasks (
+ id TEXT PRIMARY KEY,
+ content TEXT NOT NULL,
+ description TEXT DEFAULT '',
+ project_name TEXT DEFAULT '',
+ project_id TEXT DEFAULT '',
+ due_date DATETIME,
+ priority INTEGER DEFAULT 1,
+ completed BOOLEAN DEFAULT 0,
+ labels TEXT DEFAULT '[]',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ recurrence_freq TEXT DEFAULT '',
+ recurrence_interval INTEGER DEFAULT 1,
+ recurrence_weekdays TEXT DEFAULT '',
+ recurrence_series_id TEXT DEFAULT '',
+ next_occurrence_override TEXT DEFAULT '',
+ estimated_minutes INTEGER DEFAULT 0
+ )
+ `); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`
+ CREATE TABLE projects (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ color TEXT DEFAULT '',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ archived BOOLEAN DEFAULT 0,
+ budget_tracked BOOLEAN DEFAULT 0
+ )
+ `); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`
+ CREATE TABLE labels (
+ name TEXT PRIMARY KEY,
+ color TEXT NOT NULL,
+ budget_tracked BOOLEAN DEFAULT 0
+ )
+ `); err != nil {
+ t.Fatal(err)
+ }
+```
+
+- [ ] **Step 3: Add a minimal availability-only test store helper**
+
+Create `internal/store/availability_test.go` with just enough schema for the availability CRUD tests in Task 4 (kept separate since availability doesn't need native_tasks/projects/labels):
+
+```go
+package store
+
+import (
+ "database/sql"
+ "path/filepath"
+ "testing"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+// newAvailabilityTestStore creates a Store backed by a fresh temp sqlite DB
+// with just the availability_blocks table.
+func newAvailabilityTestStore(t *testing.T) *Store {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "test.db")
+ db, err := sql.Open("sqlite3", dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { db.Close() })
+ if _, err := db.Exec(`
+ CREATE TABLE availability_blocks (
+ id TEXT PRIMARY KEY,
+ weekday INTEGER NOT NULL,
+ start_time TEXT NOT NULL,
+ end_time TEXT NOT NULL,
+ label TEXT DEFAULT ''
+ )
+ `); err != nil {
+ t.Fatal(err)
+ }
+ return &Store{db: db}
+}
+```
+
+- [ ] **Step 4: Run the full suite to confirm nothing broke**
+
+Run: `go test ./internal/store/... -v`
+Expected: All existing tests still PASS (the new columns default harmlessly; no test yet references them).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add migrations/025_task_budgets_and_availability.sql internal/store/sqlite_test.go internal/store/native_tasks_test.go internal/store/availability_test.go
+git commit -m "Add schema for task budgets and availability blocks"
+```
+
+---
+
+### Task 2: Models
+
+**Files:**
+- Modify: `internal/models/types.go` (Task, Project, LabelColor structs)
+- Create: `internal/models/budget.go`
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces: `models.Task.EstimatedMinutes int`; `models.Project.BudgetTracked bool`; `models.LabelColor.BudgetTracked bool`; `models.AvailabilityBlock{ID, Weekday, StartTime, EndTime, Label}`; `models.BudgetPeriod{ScheduledMinutes, AvailableMinutes int}`; `models.BudgetStatus{Today, Week BudgetPeriod}`.
+
+- [ ] **Step 1: Add fields to existing structs**
+
+In `internal/models/types.go`, add `EstimatedMinutes` to `Task` (after `Labels`):
+
+```go
+ Labels []string `json:"labels"`
+ EstimatedMinutes int `json:"estimated_minutes,omitempty"`
+ URL string `json:"url"`
+```
+
+Add `BudgetTracked` to `Project` (after `Archived`):
+
+```go
+type Project struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+ CreatedAt time.Time `json:"created_at"`
+ Archived bool `json:"archived"`
+ BudgetTracked bool `json:"budget_tracked"`
+}
+```
+
+Add `BudgetTracked` to `LabelColor`:
+
+```go
+type LabelColor struct {
+ Name string `json:"name"`
+ Color string `json:"color"`
+ BudgetTracked bool `json:"budget_tracked"`
+}
+```
+
+- [ ] **Step 2: Create the new budget model file**
+
+```go
+// internal/models/budget.go
+package models
+
+// AvailabilityBlock is one recurring weekly window of time the user has
+// manually declared as available (e.g. "weekday evenings 18:00-20:00").
+// Reduced by real calendar events at computation time -- see
+// handlers.ComputeBudgetPeriod.
+type AvailabilityBlock struct {
+ ID string `json:"id"`
+ Weekday int `json:"weekday"` // 0-6, Sun-Sat
+ StartTime string `json:"start_time"` // "18:00"
+ EndTime string `json:"end_time"` // "20:00"
+ Label string `json:"label"`
+}
+
+// BudgetPeriod is the scheduled-vs-available load for one window of time.
+// Purely informational -- no field here ever drives auto-scheduling.
+type BudgetPeriod struct {
+ ScheduledMinutes int `json:"scheduled_minutes"`
+ AvailableMinutes int `json:"available_minutes"`
+}
+
+// BudgetStatus bundles today's and this rolling week's load. Only present
+// on API responses when at least one budget-tracked task exists in the
+// wider (Week) window.
+type BudgetStatus struct {
+ Today BudgetPeriod `json:"today"`
+ Week BudgetPeriod `json:"week"`
+}
+```
+
+- [ ] **Step 3: Confirm it builds**
+
+Run: `go build ./...`
+Expected: no errors (nothing references the new fields yet, so this only checks syntax).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/models/types.go internal/models/budget.go
+git commit -m "Add budget/availability model types"
+```
+
+---
+
+### Task 3: Store — estimated_minutes plumbing on native_tasks
+
+**Files:**
+- Modify: `internal/store/native_tasks.go`
+- Test: `internal/store/native_tasks_test.go`
+
+**Interfaces:**
+- Consumes: `models.Task.EstimatedMinutes` (Task 2).
+- Produces: `(s *Store) SetTaskEstimate(id string, minutes int) error`; every native-task read/write path now round-trips `EstimatedMinutes`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/store/native_tasks_test.go`:
+
+```go
+func TestCreateNativeTask_PersistsEstimatedMinutes(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ task := models.Task{ID: "t-est", Content: "Estimated task", EstimatedMinutes: 45}
+ if err := s.CreateNativeTask(task); err != nil {
+ t.Fatalf("CreateNativeTask: %v", err)
+ }
+
+ got, err := s.GetNativeTaskByID("t-est")
+ if err != nil {
+ t.Fatalf("GetNativeTaskByID: %v", err)
+ }
+ if got.EstimatedMinutes != 45 {
+ t.Errorf("EstimatedMinutes = %d, want 45", got.EstimatedMinutes)
+ }
+}
+
+func TestSetTaskEstimate_UpdatesMinutes(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ if err := s.SetTaskEstimate("real-1", 30); err != nil {
+ t.Fatalf("SetTaskEstimate: %v", err)
+ }
+
+ got, err := s.GetNativeTaskByID("real-1")
+ if err != nil {
+ t.Fatalf("GetNativeTaskByID: %v", err)
+ }
+ if got.EstimatedMinutes != 30 {
+ t.Errorf("EstimatedMinutes = %d, want 30", got.EstimatedMinutes)
+ }
+}
+
+func TestSetTaskEstimate_UnknownID_ReturnsErrNotFound(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ err := s.SetTaskEstimate("does-not-exist", 30)
+ if !errors.Is(err, ErrNativeTaskNotFound) {
+ t.Errorf("err = %v, want ErrNativeTaskNotFound", err)
+ }
+}
+
+func TestCreateNextIteration_CarriesEstimatedMinutesForward(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ due := time.Now()
+ old := models.Task{
+ ID: "t-series", Content: "Weekly review", DueDate: &due,
+ EstimatedMinutes: 60,
+ RecurrenceFreq: "weekly",
+ RecurrenceInterval: 1,
+ RecurrenceSeriesID: "series-1",
+ CreatedAt: time.Now().Add(-time.Hour),
+ }
+ if err := s.CreateNativeTask(old); err != nil {
+ t.Fatalf("CreateNativeTask: %v", err)
+ }
+ if _, err := s.db.Exec(`UPDATE native_tasks SET recurrence_freq = ?, recurrence_interval = ?, recurrence_series_id = ? WHERE id = ?`,
+ old.RecurrenceFreq, old.RecurrenceInterval, old.RecurrenceSeriesID, old.ID); err != nil {
+ t.Fatal(err)
+ }
+ stored, err := s.GetNativeTaskByID("t-series")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.CreateNextIteration(*stored); err != nil {
+ t.Fatalf("CreateNextIteration: %v", err)
+ }
+
+ series, err := s.GetSeriesNeedingNextIteration(time.Now().Add(365 * 24 * time.Hour))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var next *models.Task
+ for i := range series {
+ if series[i].ID != "t-series" {
+ next = &series[i]
+ }
+ }
+ if next == nil {
+ t.Fatal("expected a next iteration row")
+ }
+ if next.EstimatedMinutes != 60 {
+ t.Errorf("EstimatedMinutes = %d, want 60 (carried forward)", next.EstimatedMinutes)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `go test ./internal/store/... -run 'TestCreateNativeTask_PersistsEstimatedMinutes|TestSetTaskEstimate|TestCreateNextIteration_CarriesEstimatedMinutesForward' -v`
+Expected: FAIL — `SetTaskEstimate` undefined, and/or `EstimatedMinutes` always 0 (not selected/inserted yet).
+
+- [ ] **Step 3: Implement**
+
+In `internal/store/native_tasks.go`, add `estimated_minutes` to every `SELECT` column list (all 5 occurrences: `GetNativeTasks`, `GetNativeTasksByDateRange`, `GetOverdueNativeTasks`, `GetNativeTaskByID`, `GetSeriesNeedingNextIteration`) — change:
+
+```go
+ SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override
+```
+
+to:
+
+```go
+ SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes
+```
+
+Update `scanNativeTasks` to scan the extra column (add `&t.EstimatedMinutes` as the last `Scan` arg):
+
+```go
+ if err := rows.Scan(
+ &t.ID, &t.Content, &t.Description, &t.ProjectName, &t.ProjectID, &dueDateStr, &t.Priority, &t.Completed, &labelsJSON, &t.CreatedAt,
+ &t.RecurrenceFreq, &t.RecurrenceInterval, &weekdaysStr, &t.RecurrenceSeriesID, &nextOverrideStr, &t.EstimatedMinutes,
+ ); err != nil {
+ return nil, err
+ }
+```
+
+Update `CreateNativeTask`:
+
+```go
+func (s *Store) CreateNativeTask(task models.Task) error {
+ labelsJSON, _ := json.Marshal(task.Labels)
+ _, err := s.db.Exec(`
+ INSERT INTO native_tasks (id, content, description, project_name, project_id, due_date, priority, labels, estimated_minutes, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ `, task.ID, task.Content, task.Description, task.ProjectName, task.ProjectID, task.DueDate, task.Priority, string(labelsJSON), task.EstimatedMinutes)
+ return err
+}
+```
+
+Update `CreateNextIteration`'s INSERT (add `estimated_minutes` after `labels` in both the column list and the `SELECT ?, ...` list, and pass `old.EstimatedMinutes` in the matching argument position):
+
+```go
+ _, err := s.db.Exec(`
+ INSERT INTO native_tasks (
+ id, content, description, project_name, project_id, due_date, priority, labels, estimated_minutes,
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override,
+ created_at, updated_at
+ )
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+ WHERE NOT EXISTS (
+ SELECT 1 FROM native_tasks
+ WHERE recurrence_series_id = ?
+ AND (due_date > ? OR (due_date = ? AND created_at > ?))
+ )
+ `, newTaskID(), old.Content, old.Description, old.ProjectName, old.ProjectID, nextDue, old.Priority, string(labelsJSON), old.EstimatedMinutes,
+ old.RecurrenceFreq, old.RecurrenceInterval, formatWeekdays(old.RecurrenceWeekdays), old.RecurrenceSeriesID,
+ old.RecurrenceSeriesID, old.DueDate, old.DueDate, old.CreatedAt)
+ return err
+```
+
+Add `SetTaskEstimate` (place near `RescheduleNativeTask`):
+
+```go
+// SetTaskEstimate sets a task's estimated duration in minutes. Returns
+// ErrNativeTaskNotFound if id doesn't match any row.
+func (s *Store) SetTaskEstimate(id string, minutes int) error {
+ result, err := s.db.Exec(`
+ UPDATE native_tasks SET estimated_minutes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
+ `, minutes, id)
+ if err != nil {
+ return err
+ }
+ return checkRowsAffected(result)
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/store/... -run 'TestCreateNativeTask_PersistsEstimatedMinutes|TestSetTaskEstimate|TestCreateNextIteration_CarriesEstimatedMinutesForward|TestGetNativeTasks|TestGetOverdue|TestGetUndated|TestCompleteNativeTask|TestRescheduleNativeTask|TestSetTaskRecurrence' -v`
+Expected: PASS (including the pre-existing native-task tests, to confirm the new column didn't break scanning).
+
+- [ ] **Step 5: Run the full store suite**
+
+Run: `go test ./internal/store/...`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/store/native_tasks.go internal/store/native_tasks_test.go
+git commit -m "Add estimated_minutes to native task read/write/recurrence paths"
+```
+
+---
+
+### Task 4: Store — availability_blocks CRUD
+
+**Files:**
+- Create: `internal/store/availability.go`
+- Test: `internal/store/availability_test.go` (extends the file created in Task 1)
+
+**Interfaces:**
+- Consumes: `models.AvailabilityBlock` (Task 2).
+- Produces: `(s *Store) CreateAvailabilityBlock(weekday int, startTime, endTime, label string) (*models.AvailabilityBlock, error)`; `(s *Store) GetAvailabilityBlocks() ([]models.AvailabilityBlock, error)`; `(s *Store) DeleteAvailabilityBlock(id string) error`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/store/availability_test.go`:
+
+```go
+func TestCreateAvailabilityBlock_ReturnsCreatedBlock(t *testing.T) {
+ s := newAvailabilityTestStore(t)
+
+ block, err := s.CreateAvailabilityBlock(1, "18:00", "20:00", "evening focus")
+ if err != nil {
+ t.Fatalf("CreateAvailabilityBlock: %v", err)
+ }
+ if block.ID == "" {
+ t.Error("expected a generated ID")
+ }
+ if block.Weekday != 1 || block.StartTime != "18:00" || block.EndTime != "20:00" || block.Label != "evening focus" {
+ t.Errorf("block = %+v, unexpected field values", block)
+ }
+}
+
+func TestGetAvailabilityBlocks_ReturnsAllOrderedByWeekday(t *testing.T) {
+ s := newAvailabilityTestStore(t)
+
+ if _, err := s.CreateAvailabilityBlock(3, "09:00", "10:00", ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.CreateAvailabilityBlock(1, "18:00", "20:00", ""); err != nil {
+ t.Fatal(err)
+ }
+
+ blocks, err := s.GetAvailabilityBlocks()
+ if err != nil {
+ t.Fatalf("GetAvailabilityBlocks: %v", err)
+ }
+ if len(blocks) != 2 {
+ t.Fatalf("len(blocks) = %d, want 2", len(blocks))
+ }
+ if blocks[0].Weekday != 1 || blocks[1].Weekday != 3 {
+ t.Errorf("expected weekday-ascending order, got %d then %d", blocks[0].Weekday, blocks[1].Weekday)
+ }
+}
+
+func TestDeleteAvailabilityBlock_RemovesIt(t *testing.T) {
+ s := newAvailabilityTestStore(t)
+
+ block, err := s.CreateAvailabilityBlock(2, "07:00", "08:00", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.DeleteAvailabilityBlock(block.ID); err != nil {
+ t.Fatalf("DeleteAvailabilityBlock: %v", err)
+ }
+
+ blocks, err := s.GetAvailabilityBlocks()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(blocks) != 0 {
+ t.Errorf("expected no blocks after delete, got %d", len(blocks))
+ }
+}
+
+func TestDeleteAvailabilityBlock_UnknownID_ReturnsErrNotFound(t *testing.T) {
+ s := newAvailabilityTestStore(t)
+
+ err := s.DeleteAvailabilityBlock("does-not-exist")
+ if !errors.Is(err, ErrNativeTaskNotFound) {
+ t.Errorf("err = %v, want ErrNativeTaskNotFound", err)
+ }
+}
+```
+
+Add `"errors"` and `"testing"` to that file's imports if not already present (it already has `testing`; add `errors`).
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `go test ./internal/store/... -run 'AvailabilityBlock' -v`
+Expected: FAIL — `CreateAvailabilityBlock`/`GetAvailabilityBlocks`/`DeleteAvailabilityBlock` undefined.
+
+- [ ] **Step 3: Implement**
+
+```go
+// internal/store/availability.go
+package store
+
+import (
+ "task-dashboard/internal/models"
+)
+
+// CreateAvailabilityBlock inserts a new weekly availability block and returns it.
+func (s *Store) CreateAvailabilityBlock(weekday int, startTime, endTime, label string) (*models.AvailabilityBlock, error) {
+ id := newTaskID()
+ if _, err := s.db.Exec(`
+ INSERT INTO availability_blocks (id, weekday, start_time, end_time, label) VALUES (?, ?, ?, ?, ?)
+ `, id, weekday, startTime, endTime, label); err != nil {
+ return nil, err
+ }
+ return &models.AvailabilityBlock{ID: id, Weekday: weekday, StartTime: startTime, EndTime: endTime, Label: label}, nil
+}
+
+// GetAvailabilityBlocks returns every availability block, ordered by weekday
+// then start time.
+func (s *Store) GetAvailabilityBlocks() ([]models.AvailabilityBlock, error) {
+ rows, err := s.db.Query(`
+ SELECT id, weekday, start_time, end_time, label FROM availability_blocks ORDER BY weekday ASC, start_time ASC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var blocks []models.AvailabilityBlock
+ for rows.Next() {
+ var b models.AvailabilityBlock
+ if err := rows.Scan(&b.ID, &b.Weekday, &b.StartTime, &b.EndTime, &b.Label); err != nil {
+ return nil, err
+ }
+ blocks = append(blocks, b)
+ }
+ return blocks, rows.Err()
+}
+
+// DeleteAvailabilityBlock removes an availability block. Returns
+// ErrNativeTaskNotFound if id doesn't match any row (reusing the sentinel
+// already shared across native-task/project not-found cases).
+func (s *Store) DeleteAvailabilityBlock(id string) error {
+ result, err := s.db.Exec(`DELETE FROM availability_blocks WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ return checkRowsAffected(result)
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/store/... -run 'AvailabilityBlock' -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/store/availability.go internal/store/availability_test.go
+git commit -m "Add availability_blocks CRUD to the store layer"
+```
+
+---
+
+### Task 5: Store — budget-tracked toggles for projects/labels
+
+**Files:**
+- Modify: `internal/store/projects.go`
+- Modify: `internal/store/labels.go`
+- Test: `internal/store/projects_test.go`
+- Test: `internal/store/labels_test.go`
+
+**Interfaces:**
+- Consumes: `newNativeTasksTestStore` (Task 1, now includes `budget_tracked` columns).
+- Produces: `(s *Store) SetProjectBudgetTracked(id string, tracked bool) error`; `(s *Store) SetLabelBudgetTracked(name string, tracked bool) error`; `(s *Store) GetBudgetTrackedProjectIDs() (map[string]bool, error)`; `(s *Store) GetBudgetTrackedLabelNames() (map[string]bool, error)`. Also fixes `SetLabelColor` so it stops wiping `budget_tracked` on every color change.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/store/projects_test.go`:
+
+```go
+func TestSetProjectBudgetTracked_TogglesFlag(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ project, err := s.CreateProject("Sailing prep", "#3B82F6")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.SetProjectBudgetTracked(project.ID, true); err != nil {
+ t.Fatalf("SetProjectBudgetTracked: %v", err)
+ }
+ got, err := s.GetProjectByID(project.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.BudgetTracked {
+ t.Error("expected BudgetTracked = true")
+ }
+
+ if err := s.SetProjectBudgetTracked(project.ID, false); err != nil {
+ t.Fatal(err)
+ }
+ got, err = s.GetProjectByID(project.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.BudgetTracked {
+ t.Error("expected BudgetTracked = false after untoggling")
+ }
+}
+
+func TestSetProjectBudgetTracked_UnknownID_ReturnsErrNotFound(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ if err := s.SetProjectBudgetTracked("does-not-exist", true); !errors.Is(err, ErrNativeTaskNotFound) {
+ t.Errorf("err = %v, want ErrNativeTaskNotFound", err)
+ }
+}
+
+func TestGetBudgetTrackedProjectIDs_ReturnsOnlyTracked(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ tracked, err := s.CreateProject("Tracked", "#111111")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.CreateProject("Untracked", "#222222"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetProjectBudgetTracked(tracked.ID, true); err != nil {
+ t.Fatal(err)
+ }
+
+ ids, err := s.GetBudgetTrackedProjectIDs()
+ if err != nil {
+ t.Fatalf("GetBudgetTrackedProjectIDs: %v", err)
+ }
+ if !ids[tracked.ID] {
+ t.Error("expected tracked project id present")
+ }
+ if len(ids) != 1 {
+ t.Errorf("len(ids) = %d, want 1", len(ids))
+ }
+}
+```
+
+Add `"errors"` to `projects_test.go`'s imports.
+
+Append to `internal/store/labels_test.go`:
+
+```go
+func TestSetLabelBudgetTracked_TogglesFlag(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ if err := s.SetLabelBudgetTracked("errands", true); err != nil {
+ t.Fatalf("SetLabelBudgetTracked: %v", err)
+ }
+ names, err := s.GetBudgetTrackedLabelNames()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !names["errands"] {
+ t.Error("expected 'errands' to be tracked")
+ }
+}
+
+func TestSetLabelColor_PreservesExistingBudgetTracked(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ if err := s.SetLabelBudgetTracked("errands", true); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetLabelColor("errands", "#00ff00"); err != nil {
+ t.Fatalf("SetLabelColor: %v", err)
+ }
+
+ names, err := s.GetBudgetTrackedLabelNames()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !names["errands"] {
+ t.Error("expected budget_tracked to survive a later SetLabelColor call")
+ }
+ colors, err := s.GetLabelColors()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(colors) != 1 || colors[0].Color != "#00ff00" {
+ t.Errorf("colors = %+v, want one entry with color #00ff00", colors)
+ }
+}
+
+func TestSetLabelBudgetTracked_PreservesExistingColor(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ if err := s.SetLabelColor("errands", "#00ff00"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetLabelBudgetTracked("errands", true); err != nil {
+ t.Fatal(err)
+ }
+
+ colors, err := s.GetLabelColors()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(colors) != 1 || colors[0].Color != "#00ff00" {
+ t.Errorf("colors = %+v, want color to survive SetLabelBudgetTracked", colors)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `go test ./internal/store/... -run 'BudgetTracked' -v`
+Expected: FAIL — new functions undefined; `TestSetLabelColor_PreservesExistingBudgetTracked` would also fail once implemented naively with `INSERT OR REPLACE` (documents the bug this task fixes).
+
+- [ ] **Step 3: Implement**
+
+In `internal/store/projects.go`, add after `SetTaskProject`:
+
+```go
+// SetProjectBudgetTracked marks a project as opted in (or out) of budget
+// tracking. Returns ErrNativeTaskNotFound if id doesn't match any row.
+func (s *Store) SetProjectBudgetTracked(id string, tracked bool) error {
+ result, err := s.db.Exec(`UPDATE projects SET budget_tracked = ? WHERE id = ?`, tracked, id)
+ if err != nil {
+ return err
+ }
+ return checkRowsAffected(result)
+}
+
+// GetBudgetTrackedProjectIDs returns the set of project IDs opted into budget tracking.
+func (s *Store) GetBudgetTrackedProjectIDs() (map[string]bool, error) {
+ rows, err := s.db.Query(`SELECT id FROM projects WHERE budget_tracked = 1`)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ ids := make(map[string]bool)
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ ids[id] = true
+ }
+ return ids, rows.Err()
+}
+```
+
+Also update `GetProjects` and `GetProjectByID` in the same file to select/scan the new column:
+
+```go
+func (s *Store) GetProjects() ([]models.Project, error) {
+ rows, err := s.db.Query(`
+ SELECT id, name, color, created_at, archived, budget_tracked FROM projects WHERE archived = 0 ORDER BY name ASC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var projects []models.Project
+ for rows.Next() {
+ var p models.Project
+ if err := rows.Scan(&p.ID, &p.Name, &p.Color, &p.CreatedAt, &p.Archived, &p.BudgetTracked); err != nil {
+ return nil, err
+ }
+ projects = append(projects, p)
+ }
+ return projects, rows.Err()
+}
+
+func (s *Store) GetProjectByID(id string) (*models.Project, error) {
+ var p models.Project
+ err := s.db.QueryRow(`
+ SELECT id, name, color, created_at, archived, budget_tracked FROM projects WHERE id = ?
+ `, id).Scan(&p.ID, &p.Name, &p.Color, &p.CreatedAt, &p.Archived, &p.BudgetTracked)
+ if err == sql.ErrNoRows {
+ return nil, ErrNativeTaskNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &p, nil
+}
+```
+
+In `internal/store/labels.go`, replace `SetLabelColor` (the existing `INSERT OR REPLACE` silently resets `budget_tracked` to its default on every color change since REPLACE deletes-then-reinserts the whole row) and add the new functions:
+
+```go
+// SetLabelColor assigns (or reassigns) a label's display color, preserving
+// any existing budget_tracked flag -- a plain INSERT OR REPLACE would
+// delete-and-reinsert the row, silently resetting budget_tracked to 0.
+func (s *Store) SetLabelColor(name, color string) error {
+ _, err := s.db.Exec(`
+ INSERT INTO labels (name, color, budget_tracked) VALUES (?, ?, 0)
+ ON CONFLICT(name) DO UPDATE SET color = excluded.color
+ `, name, color)
+ return err
+}
+
+// SetLabelBudgetTracked marks a label as opted in (or out) of budget
+// tracking, preserving any existing color the same way SetLabelColor
+// preserves budget_tracked.
+func (s *Store) SetLabelBudgetTracked(name string, tracked bool) error {
+ _, err := s.db.Exec(`
+ INSERT INTO labels (name, color, budget_tracked) VALUES (?, '', ?)
+ ON CONFLICT(name) DO UPDATE SET budget_tracked = excluded.budget_tracked
+ `, name, tracked)
+ return err
+}
+
+// GetBudgetTrackedLabelNames returns the set of label names opted into budget tracking.
+func (s *Store) GetBudgetTrackedLabelNames() (map[string]bool, error) {
+ rows, err := s.db.Query(`SELECT name FROM labels WHERE budget_tracked = 1`)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ names := make(map[string]bool)
+ for rows.Next() {
+ var name string
+ if err := rows.Scan(&name); err != nil {
+ return nil, err
+ }
+ names[name] = true
+ }
+ return names, rows.Err()
+}
+```
+
+Also update `GetLabelColors` to select/scan the new column:
+
+```go
+func (s *Store) GetLabelColors() ([]models.LabelColor, error) {
+ rows, err := s.db.Query(`SELECT name, color, budget_tracked FROM labels ORDER BY name ASC`)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var colors []models.LabelColor
+ for rows.Next() {
+ var c models.LabelColor
+ if err := rows.Scan(&c.Name, &c.Color, &c.BudgetTracked); err != nil {
+ return nil, err
+ }
+ colors = append(colors, c)
+ }
+ return colors, rows.Err()
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/store/... -run 'BudgetTracked|Project|Label' -v`
+Expected: PASS
+
+- [ ] **Step 5: Run the full store suite**
+
+Run: `go test ./internal/store/...`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/store/projects.go internal/store/labels.go internal/store/projects_test.go internal/store/labels_test.go
+git commit -m "Add budget-tracked toggles for projects/labels; fix SetLabelColor wiping the flag"
+```
+
+---
+
+### Task 6: Store — same-project/label estimate averaging (inference)
+
+**Files:**
+- Create: `internal/store/estimate_inference.go`
+- Test: `internal/store/estimate_inference_test.go`
+
+**Interfaces:**
+- Consumes: `native_tasks.project_id`/`labels`/`estimated_minutes` (Task 3).
+- Produces: `(s *Store) AverageEstimateForProject(projectID string) (minutes int, ok bool, err error)`; `(s *Store) AverageEstimateForLabel(label string) (minutes int, ok bool, err error)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/store/estimate_inference_test.go
+package store
+
+import (
+ "testing"
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+func TestAverageEstimateForProject_AveragesUserEnteredEstimates(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ project, err := s.CreateProject("Sailing prep", "#3B82F6")
+ if err != nil {
+ t.Fatal(err)
+ }
+ due := time.Now()
+ for _, minutes := range []int{30, 60} {
+ task := models.Task{ID: newTaskID(), Content: "task", ProjectID: project.ID, EstimatedMinutes: minutes, DueDate: &due}
+ if err := s.CreateNativeTask(task); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // An unestimated task under the same project must not skew the average.
+ if err := s.CreateNativeTask(models.Task{ID: "t-unestimated", Content: "no estimate", ProjectID: project.ID}); err != nil {
+ t.Fatal(err)
+ }
+
+ avg, ok, err := s.AverageEstimateForProject(project.ID)
+ if err != nil {
+ t.Fatalf("AverageEstimateForProject: %v", err)
+ }
+ if !ok {
+ t.Fatal("expected ok = true")
+ }
+ if avg != 45 {
+ t.Errorf("avg = %d, want 45", avg)
+ }
+}
+
+func TestAverageEstimateForProject_NoEstimatedTasks_ReturnsNotOK(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ project, err := s.CreateProject("Empty", "#111111")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, ok, err := s.AverageEstimateForProject(project.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ok {
+ t.Error("expected ok = false when no tasks have an estimate")
+ }
+}
+
+func TestAverageEstimateForLabel_AveragesUserEnteredEstimates(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ for _, minutes := range []int{20, 40} {
+ task := models.Task{ID: newTaskID(), Content: "task", Labels: []string{"errands"}, EstimatedMinutes: minutes}
+ if err := s.CreateNativeTask(task); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := s.CreateNativeTask(models.Task{ID: "t-other-label", Content: "other", Labels: []string{"unrelated"}, EstimatedMinutes: 100}); err != nil {
+ t.Fatal(err)
+ }
+
+ avg, ok, err := s.AverageEstimateForLabel("errands")
+ if err != nil {
+ t.Fatalf("AverageEstimateForLabel: %v", err)
+ }
+ if !ok {
+ t.Fatal("expected ok = true")
+ }
+ if avg != 30 {
+ t.Errorf("avg = %d, want 30", avg)
+ }
+}
+
+func TestAverageEstimateForLabel_NoMatches_ReturnsNotOK(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ _, ok, err := s.AverageEstimateForLabel("nonexistent")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ok {
+ t.Error("expected ok = false")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `go test ./internal/store/... -run 'AverageEstimate' -v`
+Expected: FAIL — functions undefined.
+
+- [ ] **Step 3: Implement**
+
+```go
+// internal/store/estimate_inference.go
+package store
+
+import "encoding/json"
+
+// AverageEstimateForProject returns the rounded average estimated_minutes
+// across all user-estimated (estimated_minutes > 0) tasks under projectID.
+// ok is false when no such task exists -- there's no signal to infer from.
+func (s *Store) AverageEstimateForProject(projectID string) (int, bool, error) {
+ var sum, count int
+ rows, err := s.db.Query(`SELECT estimated_minutes FROM native_tasks WHERE project_id = ? AND estimated_minutes > 0`, projectID)
+ if err != nil {
+ return 0, false, err
+ }
+ defer func() { _ = rows.Close() }()
+ for rows.Next() {
+ var minutes int
+ if err := rows.Scan(&minutes); err != nil {
+ return 0, false, err
+ }
+ sum += minutes
+ count++
+ }
+ if err := rows.Err(); err != nil {
+ return 0, false, err
+ }
+ if count == 0 {
+ return 0, false, nil
+ }
+ return sum / count, true, nil
+}
+
+// AverageEstimateForLabel returns the rounded average estimated_minutes
+// across all user-estimated tasks carrying the given label. Labels are
+// stored as a JSON array column, not a joinable table, so this scans every
+// estimated task and filters in Go rather than risking a SQL substring
+// false-positive (e.g. LIKE '%"run"%' matching a task labeled "running").
+func (s *Store) AverageEstimateForLabel(label string) (int, bool, error) {
+ rows, err := s.db.Query(`SELECT labels, estimated_minutes FROM native_tasks WHERE estimated_minutes > 0`)
+ if err != nil {
+ return 0, false, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var sum, count int
+ for rows.Next() {
+ var labelsJSON string
+ var minutes int
+ if err := rows.Scan(&labelsJSON, &minutes); err != nil {
+ return 0, false, err
+ }
+ var labels []string
+ if err := json.Unmarshal([]byte(labelsJSON), &labels); err != nil {
+ continue
+ }
+ for _, l := range labels {
+ if l == label {
+ sum += minutes
+ count++
+ break
+ }
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return 0, false, err
+ }
+ if count == 0 {
+ return 0, false, nil
+ }
+ return sum / count, true, nil
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/store/... -run 'AverageEstimate' -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/store/estimate_inference.go internal/store/estimate_inference_test.go
+git commit -m "Infer a default estimate from same-project/label averages"
+```
+
+---
+
+### Task 7: Pure computation — ComputeBudgetPeriod
+
+**Files:**
+- Create: `internal/handlers/budget_logic.go`
+- Test: `internal/handlers/budget_logic_test.go`
+
+**Interfaces:**
+- Consumes: `models.AvailabilityBlock`, `models.CalendarEvent`, `models.Task`, `models.BudgetPeriod` (Task 2).
+- Produces: `ComputeBudgetPeriod(blocks []models.AvailabilityBlock, events []models.CalendarEvent, tasks []models.Task, trackedProjects, trackedLabels map[string]bool, start, end time.Time) models.BudgetPeriod`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/handlers/budget_logic_test.go
+package handlers
+
+import (
+ "testing"
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+func mustParseInLoc(t *testing.T, layout, value string, loc *time.Location) time.Time {
+ t.Helper()
+ parsed, err := time.ParseInLocation(layout, value, loc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return parsed
+}
+
+func TestComputeBudgetPeriod_SumsAvailabilityMinusOverlappingEvent(t *testing.T) {
+ loc := time.UTC
+ // A Wednesday: 2026-07-15 is a Wednesday.
+ day := mustParseInLoc(t, "2006-01-02", "2026-07-15", loc)
+ start := day
+ end := day.AddDate(0, 0, 1)
+
+ blocks := []models.AvailabilityBlock{
+ {ID: "b1", Weekday: int(day.Weekday()), StartTime: "18:00", EndTime: "20:00"}, // 120 min
+ }
+ events := []models.CalendarEvent{
+ {ID: "e1", Start: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 18:30", loc), End: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 19:00", loc)}, // 30 min overlap
+ }
+
+ status := ComputeBudgetPeriod(blocks, events, nil, nil, nil, start, end)
+ if status.AvailableMinutes != 90 {
+ t.Errorf("AvailableMinutes = %d, want 90 (120 - 30 overlap)", status.AvailableMinutes)
+ }
+}
+
+func TestComputeBudgetPeriod_EventFullyOutsideBlockDoesNotReduceIt(t *testing.T) {
+ loc := time.UTC
+ day := mustParseInLoc(t, "2006-01-02", "2026-07-15", loc)
+ start := day
+ end := day.AddDate(0, 0, 1)
+
+ blocks := []models.AvailabilityBlock{
+ {ID: "b1", Weekday: int(day.Weekday()), StartTime: "18:00", EndTime: "20:00"},
+ }
+ events := []models.CalendarEvent{
+ {ID: "e1", Start: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 09:00", loc), End: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 10:00", loc)},
+ }
+
+ status := ComputeBudgetPeriod(blocks, events, nil, nil, nil, start, end)
+ if status.AvailableMinutes != 120 {
+ t.Errorf("AvailableMinutes = %d, want 120 (event doesn't overlap the block)", status.AvailableMinutes)
+ }
+}
+
+func TestComputeBudgetPeriod_AvailableNeverGoesNegative(t *testing.T) {
+ loc := time.UTC
+ day := mustParseInLoc(t, "2006-01-02", "2026-07-15", loc)
+ start := day
+ end := day.AddDate(0, 0, 1)
+
+ blocks := []models.AvailabilityBlock{
+ {ID: "b1", Weekday: int(day.Weekday()), StartTime: "18:00", EndTime: "20:00"},
+ }
+ events := []models.CalendarEvent{
+ {ID: "e1", Start: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 17:00", loc), End: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 21:00", loc)},
+ }
+
+ status := ComputeBudgetPeriod(blocks, events, nil, nil, nil, start, end)
+ if status.AvailableMinutes != 0 {
+ t.Errorf("AvailableMinutes = %d, want 0 (event fully covers the block)", status.AvailableMinutes)
+ }
+}
+
+func TestComputeBudgetPeriod_OnlySumsTrackedIncompleteTasksDueInWindow(t *testing.T) {
+ loc := time.UTC
+ start := mustParseInLoc(t, "2006-01-02", "2026-07-15", loc)
+ end := start.AddDate(0, 0, 1)
+ due := mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 12:00", loc)
+ outsideWindow := end.AddDate(0, 0, 5)
+
+ tasks := []models.Task{
+ {ID: "t-tracked-project", ProjectID: "p1", EstimatedMinutes: 30, DueDate: &due},
+ {ID: "t-tracked-label", Labels: []string{"errands"}, EstimatedMinutes: 20, DueDate: &due},
+ {ID: "t-untracked", ProjectID: "p2", EstimatedMinutes: 999, DueDate: &due},
+ {ID: "t-completed", ProjectID: "p1", EstimatedMinutes: 999, DueDate: &due, Completed: true},
+ {ID: "t-outside-window", ProjectID: "p1", EstimatedMinutes: 999, DueDate: &outsideWindow},
+ }
+ trackedProjects := map[string]bool{"p1": true}
+ trackedLabels := map[string]bool{"errands": true}
+
+ status := ComputeBudgetPeriod(nil, nil, tasks, trackedProjects, trackedLabels, start, end)
+ if status.ScheduledMinutes != 50 {
+ t.Errorf("ScheduledMinutes = %d, want 50 (30 + 20, excluding untracked/completed/out-of-window)", status.ScheduledMinutes)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `go test ./internal/handlers/... -run 'TestComputeBudgetPeriod' -v`
+Expected: FAIL — `ComputeBudgetPeriod` undefined.
+
+- [ ] **Step 3: Implement**
+
+```go
+// internal/handlers/budget_logic.go
+package handlers
+
+import (
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+// ComputeBudgetPeriod is a pure function: given the weekly availability
+// template, calendar events, and candidate tasks, it returns the scheduled
+// (tracked, incomplete, due-in-window) load versus the available minutes
+// in [start, end) -- availability minus any overlapping calendar events.
+// Never mutates its inputs and never drives scheduling decisions; it only
+// answers "does this fit."
+func ComputeBudgetPeriod(
+ blocks []models.AvailabilityBlock,
+ events []models.CalendarEvent,
+ tasks []models.Task,
+ trackedProjects map[string]bool,
+ trackedLabels map[string]bool,
+ start, end time.Time,
+) models.BudgetPeriod {
+ available := 0
+ for day := start; day.Before(end); day = day.AddDate(0, 0, 1) {
+ weekday := int(day.Weekday())
+ for _, block := range blocks {
+ if block.Weekday != weekday {
+ continue
+ }
+ blockStart, blockEnd, ok := blockTimesOnDay(block, day)
+ if !ok {
+ continue
+ }
+ minutes := int(blockEnd.Sub(blockStart).Minutes())
+ for _, event := range events {
+ minutes -= overlapMinutes(blockStart, blockEnd, event.Start, event.End)
+ }
+ if minutes > 0 {
+ available += minutes
+ }
+ }
+ }
+
+ scheduled := 0
+ for _, task := range tasks {
+ if task.Completed || task.DueDate == nil {
+ continue
+ }
+ if !task.DueDate.Before(end) {
+ continue
+ }
+ if !isBudgetTracked(task, trackedProjects, trackedLabels) {
+ continue
+ }
+ scheduled += task.EstimatedMinutes
+ }
+
+ return models.BudgetPeriod{ScheduledMinutes: scheduled, AvailableMinutes: available}
+}
+
+// blockTimesOnDay resolves an availability block's "HH:MM" start/end
+// strings to concrete times on the given day. ok is false if either time
+// fails to parse (a malformed block is skipped rather than panicking).
+func blockTimesOnDay(block models.AvailabilityBlock, day time.Time) (time.Time, time.Time, bool) {
+ start, err := time.ParseInLocation("15:04", block.StartTime, day.Location())
+ if err != nil {
+ return time.Time{}, time.Time{}, false
+ }
+ end, err := time.ParseInLocation("15:04", block.EndTime, day.Location())
+ if err != nil {
+ return time.Time{}, time.Time{}, false
+ }
+ y, m, d := day.Date()
+ return time.Date(y, m, d, start.Hour(), start.Minute(), 0, 0, day.Location()),
+ time.Date(y, m, d, end.Hour(), end.Minute(), 0, 0, day.Location()), true
+}
+
+// overlapMinutes returns how many minutes [bStart, bEnd) and [eStart, eEnd) overlap.
+func overlapMinutes(bStart, bEnd, eStart, eEnd time.Time) int {
+ lo := bStart
+ if eStart.After(lo) {
+ lo = eStart
+ }
+ hi := bEnd
+ if eEnd.Before(hi) {
+ hi = eEnd
+ }
+ if hi.Before(lo) || hi.Equal(lo) {
+ return 0
+ }
+ return int(hi.Sub(lo).Minutes())
+}
+
+// isBudgetTracked reports whether task counts against any budget
+// calculation -- true if its project or any of its labels is opted in.
+func isBudgetTracked(task models.Task, trackedProjects, trackedLabels map[string]bool) bool {
+ if trackedProjects[task.ProjectID] {
+ return true
+ }
+ for _, label := range task.Labels {
+ if trackedLabels[label] {
+ return true
+ }
+ }
+ return false
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/handlers/... -run 'TestComputeBudgetPeriod' -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/handlers/budget_logic.go internal/handlers/budget_logic_test.go
+git commit -m "Add pure availability-minus-events and scheduled-load computation"
+```
+
+---
+
+### Task 8: Wire budget_status into GET /api/widget
+
+**Files:**
+- Modify: `internal/models/widget.go` (`WidgetResponse`)
+- Modify: `internal/handlers/widget.go` (new helper + `HandleWidgetGet`)
+- Test: `internal/handlers/widget_test.go`
+
+**Interfaces:**
+- Consumes: `ComputeBudgetPeriod` (Task 7), `s.GetAvailabilityBlocks`, `s.GetBudgetTrackedProjectIDs`, `s.GetBudgetTrackedLabelNames` (Tasks 4-5), `s.GetCalendarEventsByDateRange`, `s.GetNativeTasksByDateRange`, `s.GetOverdueNativeTasks` (existing).
+- Produces: `(h *Handler) computeBudgetStatus(now time.Time) (*models.BudgetStatus, error)`; `models.WidgetResponse.BudgetStatus *models.BudgetStatus`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/handlers/widget_test.go`:
+
+```go
+func TestHandleWidgetGet_NoBudgetTrackedTasks_OmitsBudgetStatus(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ req := httptest.NewRequest("GET", "/api/widget", nil)
+ w := httptest.NewRecorder()
+ h.HandleWidgetGet(w, req)
+
+ var resp models.WidgetResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatal(err)
+ }
+ if resp.BudgetStatus != nil {
+ t.Errorf("expected nil BudgetStatus with no tracked tasks, got %+v", resp.BudgetStatus)
+ }
+}
+
+func TestHandleWidgetGet_BudgetTrackedTaskDueToday_IncludesBudgetStatus(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ project, err := h.store.CreateProject("Tracked", "#111111")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.SetProjectBudgetTracked(project.ID, true); err != nil {
+ t.Fatal(err)
+ }
+ due := time.Now()
+ if err := h.store.CreateNativeTask(models.Task{ID: "t-tracked", Content: "Tracked task", ProjectID: project.ID, DueDate: &due, EstimatedMinutes: 45}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/widget", nil)
+ w := httptest.NewRecorder()
+ h.HandleWidgetGet(w, req)
+
+ var resp models.WidgetResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatal(err)
+ }
+ if resp.BudgetStatus == nil {
+ t.Fatal("expected non-nil BudgetStatus")
+ }
+ if resp.BudgetStatus.Today.ScheduledMinutes != 45 {
+ t.Errorf("Today.ScheduledMinutes = %d, want 45", resp.BudgetStatus.Today.ScheduledMinutes)
+ }
+ if resp.BudgetStatus.Week.ScheduledMinutes != 45 {
+ t.Errorf("Week.ScheduledMinutes = %d, want 45", resp.BudgetStatus.Week.ScheduledMinutes)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `go test ./internal/handlers/... -run 'TestHandleWidgetGet_.*Budget' -v`
+Expected: FAIL — `resp.BudgetStatus` doesn't exist yet (compile error) or is always nil.
+
+- [ ] **Step 3: Implement**
+
+In `internal/models/widget.go`, add to `WidgetResponse` (this file is itself inside package `models`, so the new field references `BudgetStatus` directly — no import needed):
+
+```go
+type WidgetResponse struct {
+ Now time.Time `json:"now"`
+ Items []WidgetItem `json:"items"`
+ BudgetStatus *BudgetStatus `json:"budget_status,omitempty"`
+}
+```
+
+In `internal/handlers/widget.go`, add the helper (place near `HandleWidgetGet`):
+
+```go
+// computeBudgetStatus returns budget status for "today" and a rolling
+// 7-day "week" window starting today, or nil if no budget-tracked task
+// falls in the week window (per the spec: the field is absent unless
+// budget-tracked tasks exist, so an unconfigured user sees no new UI).
+func (h *Handler) computeBudgetStatus(now time.Time) (*models.BudgetStatus, error) {
+ tz := config.GetDisplayTimezone()
+ todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, tz)
+ todayEnd := todayStart.Add(24 * time.Hour)
+ weekEnd := todayStart.AddDate(0, 0, 7)
+
+ blocks, err := h.store.GetAvailabilityBlocks()
+ if err != nil {
+ return nil, err
+ }
+ trackedProjects, err := h.store.GetBudgetTrackedProjectIDs()
+ if err != nil {
+ return nil, err
+ }
+ trackedLabels, err := h.store.GetBudgetTrackedLabelNames()
+ if err != nil {
+ return nil, err
+ }
+ events, err := h.store.GetCalendarEventsByDateRange(todayStart, weekEnd)
+ if err != nil {
+ return nil, err
+ }
+ overdue, err := h.store.GetOverdueNativeTasks(todayStart)
+ if err != nil {
+ return nil, err
+ }
+ weekTasks, err := h.store.GetNativeTasksByDateRange(todayStart, weekEnd)
+ if err != nil {
+ return nil, err
+ }
+ allTasks := append(append([]models.Task{}, overdue...), weekTasks...)
+
+ hasTracked := false
+ for _, task := range allTasks {
+ if isBudgetTracked(task, trackedProjects, trackedLabels) && !task.Completed {
+ hasTracked = true
+ break
+ }
+ }
+ if !hasTracked {
+ return nil, nil
+ }
+
+ today := ComputeBudgetPeriod(blocks, events, allTasks, trackedProjects, trackedLabels, todayStart, todayEnd)
+ week := ComputeBudgetPeriod(blocks, events, allTasks, trackedProjects, trackedLabels, todayStart, weekEnd)
+ return &models.BudgetStatus{Today: today, Week: week}, nil
+}
+```
+
+Update `HandleWidgetGet` to populate it:
+
+```go
+func (h *Handler) HandleWidgetGet(w http.ResponseWriter, r *http.Request) {
+ now := config.Now()
+ tz := config.GetDisplayTimezone()
+ start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, tz)
+ end := start.Add(48 * time.Hour)
+
+ items, err := BuildTimeline(r.Context(), h.store, start, end)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ widgetItems := make([]models.WidgetItem, 0, len(items))
+ for _, item := range items {
+ if item.DaySection == models.DaySectionToday ||
+ item.DaySection == models.DaySectionTomorrow ||
+ item.IsOverdue {
+ widgetItems = append(widgetItems, TimelineItemToWidgetItem(item))
+ }
+ }
+
+ budgetStatus, err := h.computeBudgetStatus(now)
+ if err != nil {
+ log.Printf("Warning: failed to compute budget status: %v", err)
+ }
+
+ resp := models.WidgetResponse{
+ Now: now,
+ Items: widgetItems,
+ BudgetStatus: budgetStatus,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+```
+
+Add `"log"` to `widget.go`'s imports if not already present (check first — several handler files already import it; `timeline_logic.go` does).
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/handlers/... -run 'TestHandleWidgetGet' -v`
+Expected: PASS (all `HandleWidgetGet` tests, old and new).
+
+- [ ] **Step 5: Run the full handlers suite**
+
+Run: `go test ./internal/handlers/...`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/models/widget.go internal/handlers/widget.go internal/handlers/widget_test.go
+git commit -m "Surface budget_status on GET /api/widget when tracked tasks exist"
+```
+
+---
+
+### Task 9: HTTP endpoints — availability CRUD, task estimate, budget-tracked toggles
+
+**Files:**
+- Modify: `internal/handlers/widget.go`
+- Modify: `cmd/dashboard/main.go:376-391`
+- Test: `internal/handlers/widget_test.go`
+
+**Interfaces:**
+- Consumes: store functions from Tasks 3-5.
+- Produces: `GET /api/widget/availability`, `POST /api/widget/availability`, `POST /api/widget/availability/delete`, `POST /api/widget/task/estimate`, `POST /api/widget/projects/budget-tracked`, `POST /api/widget/labels/budget-tracked`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/handlers/widget_test.go`:
+
+```go
+func TestHandleWidgetAvailabilityGet_ReturnsBlocks(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ if _, err := h.store.CreateAvailabilityBlock(1, "18:00", "20:00", "evening"); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/widget/availability", nil)
+ w := httptest.NewRecorder()
+ h.HandleWidgetAvailabilityGet(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ var blocks []models.AvailabilityBlock
+ if err := json.NewDecoder(w.Body).Decode(&blocks); err != nil {
+ t.Fatal(err)
+ }
+ if len(blocks) != 1 || blocks[0].Label != "evening" {
+ t.Errorf("blocks = %+v", blocks)
+ }
+}
+
+func TestHandleWidgetAvailabilityCreate_CreatesBlock(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ body := `{"weekday":2,"start_time":"07:00","end_time":"08:00","label":"morning walk"}`
+ req := httptest.NewRequest("POST", "/api/widget/availability", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetAvailabilityCreate(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ blocks, err := h.store.GetAvailabilityBlocks()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(blocks) != 1 || blocks[0].Weekday != 2 {
+ t.Errorf("blocks = %+v", blocks)
+ }
+}
+
+func TestHandleWidgetAvailabilityDelete_RemovesBlock(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ block, err := h.store.CreateAvailabilityBlock(3, "09:00", "10:00", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"id":"` + block.ID + `"}`
+ req := httptest.NewRequest("POST", "/api/widget/availability/delete", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetAvailabilityDelete(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ blocks, err := h.store.GetAvailabilityBlocks()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(blocks) != 0 {
+ t.Errorf("expected block deleted, got %+v", blocks)
+ }
+}
+
+func TestHandleWidgetAvailabilityDelete_UnknownID_Returns404(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ req := httptest.NewRequest("POST", "/api/widget/availability/delete", strings.NewReader(`{"id":"nope"}`))
+ w := httptest.NewRecorder()
+ h.HandleWidgetAvailabilityDelete(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", w.Code)
+ }
+}
+
+func TestHandleWidgetTaskEstimate_SetsEstimate(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ if err := h.store.CreateNativeTask(models.Task{ID: "t-1", Content: "task"}); err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"id":"t-1","estimated_minutes":25}`
+ req := httptest.NewRequest("POST", "/api/widget/task/estimate", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetTaskEstimate(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ task, err := h.store.GetNativeTaskByID("t-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if task.EstimatedMinutes != 25 {
+ t.Errorf("EstimatedMinutes = %d, want 25", task.EstimatedMinutes)
+ }
+}
+
+func TestHandleWidgetProjectsBudgetTracked_SetsFlag(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ project, err := h.store.CreateProject("P", "#111111")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"id":"` + project.ID + `","tracked":true}`
+ req := httptest.NewRequest("POST", "/api/widget/projects/budget-tracked", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetProjectsBudgetTracked(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ got, err := h.store.GetProjectByID(project.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.BudgetTracked {
+ t.Error("expected BudgetTracked = true")
+ }
+}
+
+func TestHandleWidgetLabelsBudgetTracked_SetsFlag(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ body := `{"name":"errands","tracked":true}`
+ req := httptest.NewRequest("POST", "/api/widget/labels/budget-tracked", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetLabelsBudgetTracked(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ names, err := h.store.GetBudgetTrackedLabelNames()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !names["errands"] {
+ t.Error("expected 'errands' tracked")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `go test ./internal/handlers/... -run 'TestHandleWidgetAvailability|TestHandleWidgetTaskEstimate|TestHandleWidgetProjectsBudgetTracked|TestHandleWidgetLabelsBudgetTracked' -v`
+Expected: FAIL — handlers undefined.
+
+- [ ] **Step 3: Implement**
+
+Append to `internal/handlers/widget.go`:
+
+```go
+type availabilityBlockResponse struct {
+ ID string `json:"id"`
+ Weekday int `json:"weekday"`
+ StartTime string `json:"start_time"`
+ EndTime string `json:"end_time"`
+ Label string `json:"label"`
+}
+
+func availabilityBlockToResponse(b models.AvailabilityBlock) availabilityBlockResponse {
+ return availabilityBlockResponse{ID: b.ID, Weekday: b.Weekday, StartTime: b.StartTime, EndTime: b.EndTime, Label: b.Label}
+}
+
+// HandleWidgetAvailabilityGet returns every configured availability block.
+func (h *Handler) HandleWidgetAvailabilityGet(w http.ResponseWriter, r *http.Request) {
+ blocks, err := h.store.GetAvailabilityBlocks()
+ if err != nil {
+ http.Error(w, "failed to load availability", http.StatusInternalServerError)
+ return
+ }
+ resp := make([]availabilityBlockResponse, 0, len(blocks))
+ for _, b := range blocks {
+ resp = append(resp, availabilityBlockToResponse(b))
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+type availabilityCreateRequest struct {
+ Weekday int `json:"weekday"`
+ StartTime string `json:"start_time"`
+ EndTime string `json:"end_time"`
+ Label string `json:"label"`
+}
+
+// HandleWidgetAvailabilityCreate creates a new weekly availability block.
+func (h *Handler) HandleWidgetAvailabilityCreate(w http.ResponseWriter, r *http.Request) {
+ var req availabilityCreateRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if req.Weekday < 0 || req.Weekday > 6 || req.StartTime == "" || req.EndTime == "" {
+ http.Error(w, "weekday (0-6), start_time, and end_time are required", http.StatusBadRequest)
+ return
+ }
+ block, err := h.store.CreateAvailabilityBlock(req.Weekday, req.StartTime, req.EndTime, req.Label)
+ if err != nil {
+ http.Error(w, "failed to create availability block", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(availabilityBlockToResponse(*block))
+}
+
+type availabilityDeleteRequest struct {
+ ID string `json:"id"`
+}
+
+// HandleWidgetAvailabilityDelete deletes an availability block.
+func (h *Handler) HandleWidgetAvailabilityDelete(w http.ResponseWriter, r *http.Request) {
+ var req availabilityDeleteRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if err := h.store.DeleteAvailabilityBlock(req.ID); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ http.Error(w, "availability block not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "failed to delete availability block", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+type taskEstimateRequest struct {
+ ID string `json:"id"`
+ EstimatedMinutes int `json:"estimated_minutes"`
+}
+
+// HandleWidgetTaskEstimate sets a task's estimated duration in minutes.
+func (h *Handler) HandleWidgetTaskEstimate(w http.ResponseWriter, r *http.Request) {
+ var req taskEstimateRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if req.ID == "" {
+ http.Error(w, "id is required", http.StatusBadRequest)
+ return
+ }
+ if err := h.store.SetTaskEstimate(req.ID, req.EstimatedMinutes); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ http.Error(w, "task not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "failed to set estimate", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+type projectBudgetTrackedRequest struct {
+ ID string `json:"id"`
+ Tracked bool `json:"tracked"`
+}
+
+// HandleWidgetProjectsBudgetTracked opts a project in or out of budget tracking.
+func (h *Handler) HandleWidgetProjectsBudgetTracked(w http.ResponseWriter, r *http.Request) {
+ var req projectBudgetTrackedRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if req.ID == "" {
+ http.Error(w, "id is required", http.StatusBadRequest)
+ return
+ }
+ if err := h.store.SetProjectBudgetTracked(req.ID, req.Tracked); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ http.Error(w, "project not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "failed to update project", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+type labelBudgetTrackedRequest struct {
+ Name string `json:"name"`
+ Tracked bool `json:"tracked"`
+}
+
+// HandleWidgetLabelsBudgetTracked opts a label in or out of budget tracking.
+func (h *Handler) HandleWidgetLabelsBudgetTracked(w http.ResponseWriter, r *http.Request) {
+ var req labelBudgetTrackedRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if req.Name == "" {
+ http.Error(w, "name is required", http.StatusBadRequest)
+ return
+ }
+ if err := h.store.SetLabelBudgetTracked(req.Name, req.Tracked); err != nil {
+ http.Error(w, "failed to update label", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+```
+
+Register the routes in `cmd/dashboard/main.go`, inside the `if cfg.WidgetToken != ""` block, right after the existing `HandleWidgetLabelsColorSet` line:
+
+```go
+ r.With(widgetAuth).Post("/api/widget/labels/color", h.HandleWidgetLabelsColorSet)
+ r.With(widgetAuth).Get("/api/widget/availability", h.HandleWidgetAvailabilityGet)
+ r.With(widgetAuth).Post("/api/widget/availability", h.HandleWidgetAvailabilityCreate)
+ r.With(widgetAuth).Post("/api/widget/availability/delete", h.HandleWidgetAvailabilityDelete)
+ r.With(widgetAuth).Post("/api/widget/task/estimate", h.HandleWidgetTaskEstimate)
+ r.With(widgetAuth).Post("/api/widget/projects/budget-tracked", h.HandleWidgetProjectsBudgetTracked)
+ r.With(widgetAuth).Post("/api/widget/labels/budget-tracked", h.HandleWidgetLabelsBudgetTracked)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/handlers/... -run 'TestHandleWidgetAvailability|TestHandleWidgetTaskEstimate|TestHandleWidgetProjectsBudgetTracked|TestHandleWidgetLabelsBudgetTracked' -v`
+Expected: PASS
+
+- [ ] **Step 5: Build and run the full suite**
+
+Run: `go build ./... && go test ./...`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/handlers/widget.go internal/handlers/widget_test.go cmd/dashboard/main.go
+git commit -m "Add availability CRUD, task estimate, and budget-tracked toggle endpoints"
+```
+
+---
+
+### Task 10: Suggested estimate on task detail
+
+**Files:**
+- Modify: `internal/handlers/widget.go` (`taskDetailResponse`, `HandleWidgetTaskDetail`)
+- Test: `internal/handlers/widget_test.go`
+
+**Interfaces:**
+- Consumes: `s.AverageEstimateForProject`, `s.AverageEstimateForLabel` (Task 6).
+- Produces: `taskDetailResponse.EstimatedMinutes int`; `taskDetailResponse.SuggestedEstimateMinutes *int`.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/handlers/widget_test.go`:
+
+```go
+func TestHandleWidgetTaskDetail_SuggestsEstimateFromProjectAverage(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ project, err := h.store.CreateProject("Sailing prep", "#3B82F6")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.CreateNativeTask(models.Task{ID: "t-past", Content: "past", ProjectID: project.ID, EstimatedMinutes: 40}); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.CreateNativeTask(models.Task{ID: "t-new", Content: "new", ProjectID: project.ID}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/widget/task?id=t-new", nil)
+ w := httptest.NewRecorder()
+ h.HandleWidgetTaskDetail(w, req)
+
+ var resp taskDetailResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatal(err)
+ }
+ if resp.SuggestedEstimateMinutes == nil || *resp.SuggestedEstimateMinutes != 40 {
+ t.Errorf("SuggestedEstimateMinutes = %v, want pointer to 40", resp.SuggestedEstimateMinutes)
+ }
+}
+
+func TestHandleWidgetTaskDetail_AlreadyEstimated_NoSuggestion(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ project, err := h.store.CreateProject("P", "#111111")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.CreateNativeTask(models.Task{ID: "t-past", Content: "past", ProjectID: project.ID, EstimatedMinutes: 40}); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.CreateNativeTask(models.Task{ID: "t-estimated", Content: "already estimated", ProjectID: project.ID, EstimatedMinutes: 15}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/widget/task?id=t-estimated", nil)
+ w := httptest.NewRecorder()
+ h.HandleWidgetTaskDetail(w, req)
+
+ var resp taskDetailResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatal(err)
+ }
+ if resp.SuggestedEstimateMinutes != nil {
+ t.Errorf("expected no suggestion for an already-estimated task, got %v", *resp.SuggestedEstimateMinutes)
+ }
+ if resp.EstimatedMinutes != 15 {
+ t.Errorf("EstimatedMinutes = %d, want 15", resp.EstimatedMinutes)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `go test ./internal/handlers/... -run 'TestHandleWidgetTaskDetail_Suggests|TestHandleWidgetTaskDetail_AlreadyEstimated' -v`
+Expected: FAIL — `SuggestedEstimateMinutes`/`EstimatedMinutes` don't exist on `taskDetailResponse` yet.
+
+- [ ] **Step 3: Implement**
+
+Read `internal/handlers/widget.go`'s current `HandleWidgetTaskDetail` (around line 569) before editing — it builds `taskDetailResponse` from a loaded `models.Task`. Add two fields to the struct:
+
+```go
+type taskDetailResponse struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ DueDate *time.Time `json:"due_date,omitempty"`
+ Completed bool `json:"completed"`
+ Recurrence *recurrenceResponse `json:"recurrence,omitempty"`
+ NextDate *time.Time `json:"next_date,omitempty"`
+ Project *projectResponse `json:"project,omitempty"`
+ Labels []string `json:"labels,omitempty"`
+ EstimatedMinutes int `json:"estimated_minutes,omitempty"`
+ SuggestedEstimateMinutes *int `json:"suggested_estimate_minutes,omitempty"`
+}
+```
+
+In `HandleWidgetTaskDetail`, after the task is loaded (same point the existing code reads `task.Content`/`task.Description`/etc. to populate the response) and before writing the JSON response, set the new fields:
+
+```go
+ resp.EstimatedMinutes = task.EstimatedMinutes
+ if task.EstimatedMinutes == 0 {
+ if task.ProjectID != "" {
+ if avg, ok, err := h.store.AverageEstimateForProject(task.ProjectID); err == nil && ok {
+ resp.SuggestedEstimateMinutes = &avg
+ }
+ }
+ if resp.SuggestedEstimateMinutes == nil {
+ for _, label := range task.Labels {
+ if avg, ok, err := h.store.AverageEstimateForLabel(label); err == nil && ok {
+ resp.SuggestedEstimateMinutes = &avg
+ break
+ }
+ }
+ }
+ }
+```
+
+Insert this block into the existing `HandleWidgetTaskDetail` right before its final `w.Header().Set("Content-Type", "application/json")` / `json.NewEncoder(w).Encode(resp)` lines — the function's existing response variable is named `resp`.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/handlers/... -run 'TestHandleWidgetTaskDetail' -v`
+Expected: PASS (all task-detail tests, old and new)
+
+- [ ] **Step 5: Run the full handlers suite**
+
+Run: `go test ./internal/handlers/...`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/handlers/widget.go internal/handlers/widget_test.go
+git commit -m "Suggest an estimate from same-project/label averages on task detail"
+```
+
+---
+
+### Task 11: Web timeline indicator
+
+**Files:**
+- Modify: `internal/handlers/timeline.go` (`TimelineData`, `HandleTimeline`)
+- Modify: `web/templates/partials/timeline-tab.html`
+- Test: `internal/handlers/handlers_test.go`
+
+**Interfaces:**
+- Consumes: `(h *Handler) computeBudgetStatus` (Task 8).
+- Produces: `TimelineData.BudgetStatus *models.BudgetStatus`; a small indicator line in the Today section header.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/handlers/handlers_test.go`:
+
+```go
+func TestHandleTimeline_IncludesBudgetStatusWhenTrackedTaskExists(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ project, err := h.store.CreateProject("Tracked", "#111111")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.SetProjectBudgetTracked(project.ID, true); err != nil {
+ t.Fatal(err)
+ }
+ due := time.Now()
+ if err := h.store.CreateNativeTask(models.Task{ID: "t-tracked", Content: "Tracked", ProjectID: project.ID, DueDate: &due, EstimatedMinutes: 30}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/tabs/timeline", nil)
+ w := httptest.NewRecorder()
+ h.HandleTimeline(w, req)
+
+ mock := h.renderer.(*MockRenderer)
+ lastCall := mock.Calls[len(mock.Calls)-1]
+ data, ok := lastCall.Data.(TimelineData)
+ if !ok {
+ t.Fatalf("expected TimelineData, got %T", lastCall.Data)
+ }
+ if data.BudgetStatus == nil {
+ t.Fatal("expected non-nil BudgetStatus")
+ }
+ if data.BudgetStatus.Today.ScheduledMinutes != 30 {
+ t.Errorf("Today.ScheduledMinutes = %d, want 30", data.BudgetStatus.Today.ScheduledMinutes)
+ }
+}
+```
+
+(Check `handlers_test.go`'s existing imports for `models` and `time` — both are almost certainly already imported given the file's size; add only if missing.)
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/handlers/... -run 'TestHandleTimeline_IncludesBudgetStatus' -v`
+Expected: FAIL — `TimelineData.BudgetStatus` doesn't exist.
+
+- [ ] **Step 3: Implement**
+
+In `internal/handlers/timeline.go`, add to `TimelineData` (near the other top-level fields):
+
+```go
+ // Current time for "now" line
+ NowHour int
+ NowMinute int
+
+ // Opt-in budget/availability indicator for Today -- nil when no
+ // budget-tracked task exists (see computeBudgetStatus).
+ BudgetStatus *models.BudgetStatus
+```
+
+In `HandleTimeline`, right after `data := TimelineData{...}` is populated with labels/hours and before `HTMLResponse(...)` is called at the end:
+
+```go
+ budgetStatus, err := h.computeBudgetStatus(now)
+ if err != nil {
+ log.Printf("Warning: failed to compute budget status: %v", err)
+ }
+ data.BudgetStatus = budgetStatus
+
+ HTMLResponse(w, h.renderer, "timeline-tab", data)
+```
+
+In `web/templates/partials/timeline-tab.html`, add the indicator right after the existing items-count span (line 118):
+
+```html
+ <span>📅</span> {{.TodayLabel}}
+ <span class="text-sm font-normal text-white/50">({{len .TodayItems}} items)</span>
+ {{if .BudgetStatus}}
+ <span class="text-sm font-normal text-white/50">· {{.BudgetStatus.Today.ScheduledMinutes}}m scheduled / {{.BudgetStatus.Today.AvailableMinutes}}m available</span>
+ {{end}}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/handlers/... -run 'TestHandleTimeline' -v`
+Expected: PASS (all `HandleTimeline` tests, old and new)
+
+- [ ] **Step 5: Run the full suite and build**
+
+Run: `go build ./... && go test ./...`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/handlers/timeline.go internal/handlers/handlers_test.go web/templates/partials/timeline-tab.html
+git commit -m "Show a scheduled-vs-available indicator on the web timeline's Today section"
+```
+
+---
+
+### Task 12: Android widget — TODAY header badge
+
+**Files:**
+- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt`
+- Modify: `android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt:13-21` (`Keys` object)
+- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt:54-63` (`fetchAndPersist`)
+- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt:28-51` (`DootWidget.provideGlance`, `WidgetRoot` signature/call site, TODAY header `Row`)
+
+**Interfaces:**
+- Consumes: `budget_status` field on the JSON `/api/widget` response (Task 8).
+- Produces: `Keys.BUDGET_STATUS_JSON`; `WidgetRoot(items, now, isRefreshing, textSize, budgetStatus: BudgetStatus?)`; a small badge next to "TODAY" showing e.g. "30m/90m" when `budgetStatus?.today` has a nonzero load.
+
+- [ ] **Step 1: Add the Kotlin model**
+
+In `WidgetItem.kt`, add alongside the existing `WidgetResponse`, and add the new field to `WidgetResponse` itself:
+
+```kotlin
+@Serializable
+data class BudgetPeriod(
+ @SerialName("scheduled_minutes") val scheduledMinutes: Int,
+ @SerialName("available_minutes") val availableMinutes: Int
+)
+
+@Serializable
+data class BudgetStatus(
+ val today: BudgetPeriod,
+ val week: BudgetPeriod
+)
+
+@Serializable
+data class WidgetResponse(
+ val now: String,
+ val items: List<WidgetItem>,
+ @SerialName("budget_status") val budgetStatus: BudgetStatus? = null
+)
+```
+
+- [ ] **Step 2: Add a DataStore key for the persisted budget status**
+
+`DootWidget`'s `provideGlance` doesn't read the full `WidgetResponse` — `WidgetRepository.fetchAndPersist` (see Step 3) decomposes it into individual `Keys` prefs (`ITEMS_JSON`, `NOW`, ...), and `provideGlance` re-reads those individually. Add a matching key in `DataStore.kt`:
+
+```kotlin
+object Keys {
+ val SERVER_URL = stringPreferencesKey("server_url")
+ val TOKEN = stringPreferencesKey("token")
+ val ITEMS_JSON = stringPreferencesKey("items_json")
+ val NOW = stringPreferencesKey("now")
+ val LAST_UPDATED = longPreferencesKey("last_updated")
+ val IS_REFRESHING = booleanPreferencesKey("is_refreshing")
+ val TEXT_SIZE = stringPreferencesKey("text_size")
+ val BUDGET_STATUS_JSON = stringPreferencesKey("budget_status_json")
+}
+```
+
+- [ ] **Step 3: Persist budget_status in fetchAndPersist**
+
+In `WidgetRepository.kt`, update `fetchAndPersist`:
+
+```kotlin
+ /** Fetches and persists to DataStore. Call this from workers. */
+ suspend fun fetchAndPersist(context: Context): Result<WidgetResponse> {
+ return fetchRaw().onSuccess { resp ->
+ context.dataStore.edit { prefs ->
+ prefs[Keys.ITEMS_JSON] = json.encodeToString(resp.items)
+ prefs[Keys.NOW] = resp.now
+ prefs[Keys.LAST_UPDATED] = System.currentTimeMillis()
+ if (resp.budgetStatus != null) {
+ prefs[Keys.BUDGET_STATUS_JSON] = json.encodeToString(resp.budgetStatus)
+ } else {
+ prefs.remove(Keys.BUDGET_STATUS_JSON)
+ }
+ }
+ }
+ }
+```
+
+- [ ] **Step 4: Thread budgetStatus through provideGlance and WidgetRoot**
+
+In `DootWidget.kt`:
+
+```kotlin
+class DootWidget : GlanceAppWidget() {
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val prefs = context.dataStore.data.first()
+ val items = parseItems(prefs)
+ val now = prefs[Keys.NOW]?.let { runCatching { Instant.parse(it) }.getOrNull() }
+ ?: Instant.now()
+ val isRefreshing = prefs[Keys.IS_REFRESHING] ?: false
+ val textSize = WidgetTextSize.fromPref(prefs[Keys.TEXT_SIZE])
+ val budgetStatus = prefs[Keys.BUDGET_STATUS_JSON]?.let {
+ runCatching { json.decodeFromString<BudgetStatus>(it) }.getOrNull()
+ }
+
+ provideContent {
+ WidgetRoot(items, now, isRefreshing, textSize, budgetStatus)
+ }
+ }
+
+ private fun parseItems(prefs: Preferences): List<WidgetItem> {
+ val raw = prefs[Keys.ITEMS_JSON] ?: return emptyList()
+ return runCatching { json.decodeFromString<List<WidgetItem>>(raw) }.getOrDefault(emptyList())
+ }
+}
+
+@Composable
+fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean, textSize: WidgetTextSize, budgetStatus: BudgetStatus? = null) {
+```
+
+(Only the signature line of `WidgetRoot` changes here — its body is untouched except for the header `Row` in Step 5. The added `= null` default keeps any other existing call site, e.g. in tests/previews, compiling without changes.)
+
+- [ ] **Step 5: Render the badge**
+
+In the TODAY header `Row` inside `WidgetRoot` (currently: a "TODAY" `Text` with `defaultWeight()`, then `QuickAddButton`, then `RefreshButton`), add a small badge between the title and the buttons, only when there's a nonzero tracked load:
+
+```kotlin
+ item {
+ Row(
+ modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ "TODAY",
+ style = TextStyle(
+ color = ColorProvider(Color(0x99FFFFFF)),
+ fontSize = textSize.scaledHeaderSize(11),
+ fontWeight = textSize.scaledHeaderWeight(FontWeight.Bold)
+ ),
+ modifier = GlanceModifier.defaultWeight()
+ )
+ budgetStatus?.today?.let { today ->
+ if (today.scheduledMinutes > 0) {
+ Text(
+ "${today.scheduledMinutes}m/${today.availableMinutes}m",
+ style = TextStyle(
+ color = ColorProvider(Color(0x99FFFFFF)),
+ fontSize = textSize.scaledHeaderSize(10)
+ ),
+ modifier = GlanceModifier.padding(end = 4.dp)
+ )
+ }
+ }
+ QuickAddButton()
+ Spacer(modifier = GlanceModifier.width(4.dp))
+ RefreshButton(isRefreshing)
+ }
+ }
+```
+
+- [ ] **Step 6: Build the Android app**
+
+Run: `cd android && ./gradlew assembleDebug`
+Expected: BUILD SUCCESSFUL
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt
+git commit -m "Show scheduled/available badge on widget TODAY header"
+```
+
+## Out of Scope (per spec, do not implement)
+
+- Auto-scheduling tasks into specific time slots.
+- Auto-reprioritization or auto-deferral on overflow.
+- Real elapsed-time tracking (start/stop timers).
+- Any UI beyond the basic indicators built here (e.g. a dedicated availability-editing screen) — Tasks 9's endpoints exist for a future UI to call; this plan doesn't build that settings screen.
+- Non-project/label-scoped (global) budgets.