# 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.