diff options
| -rw-r--r-- | docs/superpowers/plans/2026-07-16-task-budgets-and-availability.md | 2393 |
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. |
