From 2413413b01925c7fab07768d7e405ac13c735000 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 16 Jul 2026 06:46:53 +0000 Subject: Surface budget_status on GET /api/widget when tracked tasks exist Wires ComputeBudgetPeriod together with availability blocks, tracked project/label sets, calendar events, and native tasks into a new computeBudgetStatus helper on *Handler. HandleWidgetGet now populates WidgetResponse.BudgetStatus with today/week periods, but only when at least one incomplete budget-tracked task exists in the rolling week window -- otherwise the field is omitted entirely so unconfigured users see no new UI. Errors are logged and swallowed, matching the existing resilience pattern for this widget endpoint. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PQaPGQVSfmKUiHXB87qRTC --- internal/handlers/widget.go | 63 ++++++++++++++++++++++++++++++++++++++-- internal/handlers/widget_test.go | 54 ++++++++++++++++++++++++++++++++++ internal/models/widget.go | 5 ++-- 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index 05c0bdb..bff7dd7 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -3,6 +3,7 @@ package handlers import ( "encoding/json" "errors" + "log" "net/http" "strings" "time" @@ -126,15 +127,73 @@ func (h *Handler) HandleWidgetGet(w http.ResponseWriter, r *http.Request) { } } + 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, + Now: now, + Items: widgetItems, + BudgetStatus: budgetStatus, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } +// 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 +} + type widgetCompleteRequest struct { ID string `json:"id"` Source string `json:"source"` diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go index f934dba..a638eca 100644 --- a/internal/handlers/widget_test.go +++ b/internal/handlers/widget_test.go @@ -1214,3 +1214,57 @@ func TestTimelineItemToWidgetItem_NoProject_NilProjectColor(t *testing.T) { } } +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) + } +} + diff --git a/internal/models/widget.go b/internal/models/widget.go index da13008..bf148fd 100644 --- a/internal/models/widget.go +++ b/internal/models/widget.go @@ -23,6 +23,7 @@ type WidgetItem struct { // WidgetResponse is the full /api/widget response body. type WidgetResponse struct { - Now time.Time `json:"now"` - Items []WidgetItem `json:"items"` + Now time.Time `json:"now"` + Items []WidgetItem `json:"items"` + BudgetStatus *BudgetStatus `json:"budget_status,omitempty"` } -- cgit v1.2.3