summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-16 06:46:53 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-16 06:46:53 +0000
commit2413413b01925c7fab07768d7e405ac13c735000 (patch)
treecc168393e8a2b9bb795e3003366be14e8d8303d6
parentcef79fc49fd8d6e607c71432755812adca847ce2 (diff)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQaPGQVSfmKUiHXB87qRTC
-rw-r--r--internal/handlers/widget.go63
-rw-r--r--internal/handlers/widget_test.go54
-rw-r--r--internal/models/widget.go5
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"`
}