From de435fda250f93dc897fcb6414f418e7ed9b3595 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 16 Jul 2026 06:53:07 +0000 Subject: Add availability CRUD, task estimate, and budget-tracked toggle endpoints Adds 6 widget HTTP handlers (availability get/create/delete, task estimate, project and label budget-tracked toggles) plus route registration, following the existing widget handler conventions. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PQaPGQVSfmKUiHXB87qRTC --- internal/handlers/widget.go | 153 +++++++++++++++++++++++++++++++++++++ internal/handlers/widget_test.go | 159 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 312 insertions(+) (limited to 'internal/handlers') diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index bff7dd7..5e515c3 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -777,3 +777,156 @@ func (h *Handler) HandleWidgetTaskNextDate(w http.ResponseWriter, r *http.Reques } w.WriteHeader(http.StatusOK) } + +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) +} diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go index a638eca..10a5c02 100644 --- a/internal/handlers/widget_test.go +++ b/internal/handlers/widget_test.go @@ -1268,3 +1268,162 @@ func TestHandleWidgetGet_BudgetTrackedTaskDueToday_IncludesBudgetStatus(t *testi } } + +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") + } +} -- cgit v1.2.3