# 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 📅 {{.TodayLabel}} ({{len .TodayItems}} items) {{if .BudgetStatus}} · {{.BudgetStatus.Today.ScheduledMinutes}}m scheduled / {{.BudgetStatus.Today.AvailableMinutes}}m available {{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, @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 { 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(it) }.getOrNull() } provideContent { WidgetRoot(items, now, isRefreshing, textSize, budgetStatus) } } private fun parseItems(prefs: Preferences): List { val raw = prefs[Keys.ITEMS_JSON] ?: return emptyList() return runCatching { json.decodeFromString>(raw) }.getOrDefault(emptyList()) } } @Composable fun WidgetRoot(items: List, 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.