diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-14 20:35:55 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-16 02:44:52 +0000 |
| commit | 146034b08446c4bf0099bb41ab5e689d418e6dce (patch) | |
| tree | 46db3cca04eb3d57b58a5195bbe42d0c193a9043 /internal | |
| parent | ba5205213f6f1995ca4e64b08925bcc6c48b8211 (diff) | |
feat(tasks): add HTTP endpoints for task detail, update, and recurrence
GET /api/widget/task, POST /api/widget/task/update,
POST /api/widget/task/recurrence, POST /api/widget/task/next-date.
Doot-only; the recurrence/next-date fields are null in the detail
response for a non-recurring task.
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/handlers/widget.go | 161 | ||||
| -rw-r--r-- | internal/handlers/widget_test.go | 197 |
2 files changed, 358 insertions, 0 deletions
diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index f0fd101..15ef3b2 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -393,3 +393,164 @@ func (h *Handler) HandleWidgetAdd(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } + +type recurrenceResponse struct { + Freq string `json:"freq"` + Interval int `json:"interval"` + Weekdays []int `json:"weekdays,omitempty"` +} + +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"` +} + +// HandleWidgetTaskDetail returns full detail for a single doot-native task, +// including its recurrence pattern and computed next-occurrence date. +func (h *Handler) HandleWidgetTaskDetail(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + source := r.URL.Query().Get("source") + if id == "" || source != "doot" { + http.Error(w, "id required and source must be doot", http.StatusBadRequest) + return + } + + task, err := h.store.GetNativeTaskByID(id) + if err != nil { + if errors.Is(err, store.ErrNativeTaskNotFound) { + http.Error(w, "task not found", http.StatusNotFound) + return + } + http.Error(w, "failed to load task", http.StatusInternalServerError) + return + } + + resp := taskDetailResponse{ + ID: task.ID, + Title: task.Content, + Description: task.Description, + DueDate: task.DueDate, + Completed: task.Completed, + } + if task.RecurrenceSeriesID != "" { + resp.Recurrence = &recurrenceResponse{ + Freq: task.RecurrenceFreq, + Interval: task.RecurrenceInterval, + Weekdays: task.RecurrenceWeekdays, + } + if task.NextOccurrenceOverride != nil { + resp.NextDate = task.NextOccurrenceOverride + } else if task.DueDate != nil { + next := models.ComputeNextOccurrence(*task.DueDate, task.RecurrenceFreq, task.RecurrenceInterval, task.RecurrenceWeekdays) + resp.NextDate = &next + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +type taskUpdateRequest struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` +} + +// HandleWidgetTaskUpdate updates a doot-native task's title and description. +func (h *Handler) HandleWidgetTaskUpdate(w http.ResponseWriter, r *http.Request) { + var req taskUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if req.ID == "" || req.Title == "" { + http.Error(w, "id and title are required", http.StatusBadRequest) + return + } + if err := h.store.UpdateNativeTask(req.ID, req.Title, req.Description); err != nil { + http.Error(w, "failed to update task", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +type taskRecurrenceRequest struct { + ID string `json:"id"` + Freq string `json:"freq"` + Interval int `json:"interval"` + Weekdays []int `json:"weekdays"` +} + +// HandleWidgetTaskRecurrence sets or clears a doot-native task's recurrence +// pattern. freq == "" clears recurrence. +func (h *Handler) HandleWidgetTaskRecurrence(w http.ResponseWriter, r *http.Request) { + var req taskRecurrenceRequest + 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 req.Freq != "" && req.Freq != "daily" && req.Freq != "weekly" && req.Freq != "monthly" && req.Freq != "yearly" { + http.Error(w, "invalid freq", http.StatusBadRequest) + return + } + if err := h.store.SetTaskRecurrence(req.ID, req.Freq, req.Interval, req.Weekdays); err != nil { + if errors.Is(err, store.ErrNativeTaskNotFound) { + http.Error(w, "task not found", http.StatusNotFound) + return + } + http.Error(w, "failed to update recurrence", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +type taskNextDateRequest struct { + ID string `json:"id"` + Date string `json:"date"` // YYYY-MM-DD +} + +// HandleWidgetTaskNextDate sets a one-shot override for a recurring task's +// next occurrence. 400 if the task has no active recurrence. +func (h *Handler) HandleWidgetTaskNextDate(w http.ResponseWriter, r *http.Request) { + var req taskNextDateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + parsed, err := time.Parse("2006-01-02", req.Date) + if err != nil { + http.Error(w, "invalid date", http.StatusBadRequest) + return + } + + task, err := h.store.GetNativeTaskByID(req.ID) + if err != nil { + if errors.Is(err, store.ErrNativeTaskNotFound) { + http.Error(w, "task not found", http.StatusNotFound) + return + } + http.Error(w, "failed to load task", http.StatusInternalServerError) + return + } + if task.RecurrenceSeriesID == "" { + http.Error(w, "task has no active recurrence", http.StatusBadRequest) + return + } + + tz := config.GetDisplayTimezone() + dueDate := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, tz) + if err := h.store.SetNextOccurrenceOverride(req.ID, dueDate); err != nil { + http.Error(w, "failed to set next date", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go index 2a6967e..35c21e2 100644 --- a/internal/handlers/widget_test.go +++ b/internal/handlers/widget_test.go @@ -816,3 +816,200 @@ func TestTimelineItemToWidgetItem_NonRecurring_EmptyRecurringEventID(t *testing. } } +func TestHandleWidgetTaskDetail_ReturnsFullDetail(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + + if _, err := s.DB().Exec(` + INSERT INTO native_tasks (id, content, description, due_date, recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id) + VALUES ('rec-1', 'Water plants', 'Use the blue can', '2026-07-13', 'weekly', 1, '1,3,5', 'series-1') + `); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/widget/task?id=rec-1&source=doot", nil) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetTaskDetail).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp taskDetailResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Title != "Water plants" { + t.Errorf("Title = %q, want %q", resp.Title, "Water plants") + } + if resp.Description != "Use the blue can" { + t.Errorf("Description = %q, want %q", resp.Description, "Use the blue can") + } + if resp.Recurrence == nil { + t.Fatal("expected non-nil Recurrence") + } + if resp.Recurrence.Freq != "weekly" || len(resp.Recurrence.Weekdays) != 3 { + t.Errorf("Recurrence = %+v, want freq=weekly with 3 weekdays", resp.Recurrence) + } + if resp.NextDate == nil { + t.Fatal("expected computed NextDate for a recurring task") + } +} + +func TestHandleWidgetTaskDetail_NonRecurring_NilRecurrenceAndNextDate(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + + if _, err := s.DB().Exec(`INSERT INTO native_tasks (id, content) VALUES ('plain-1', 'Buy milk')`); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/widget/task?id=plain-1&source=doot", nil) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetTaskDetail).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp taskDetailResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Recurrence != nil { + t.Errorf("expected nil Recurrence for a non-recurring task, got %+v", resp.Recurrence) + } + if resp.NextDate != nil { + t.Errorf("expected nil NextDate for a non-recurring task, got %v", resp.NextDate) + } +} + +func TestHandleWidgetTaskDetail_UnknownID_Returns404(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + + req := httptest.NewRequest("GET", "/api/widget/task?id=does-not-exist&source=doot", nil) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetTaskDetail).ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestHandleWidgetTaskUpdate_UpdatesTitleAndDescription(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + + if _, err := s.DB().Exec(`INSERT INTO native_tasks (id, content) VALUES ('plain-1', 'Old title')`); err != nil { + t.Fatal(err) + } + + body := `{"id":"plain-1","title":"New title","description":"New description"}` + req := httptest.NewRequest("POST", "/api/widget/task/update", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetTaskUpdate).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + task, err := s.GetNativeTaskByID("plain-1") + if err != nil { + t.Fatal(err) + } + if task.Content != "New title" || task.Description != "New description" { + t.Errorf("task = %+v, want title/description updated", task) + } +} + +func TestHandleWidgetTaskRecurrence_SetsPattern(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + + if _, err := s.DB().Exec(`INSERT INTO native_tasks (id, content) VALUES ('plain-1', 'Water plants')`); err != nil { + t.Fatal(err) + } + + body := `{"id":"plain-1","freq":"weekly","interval":2,"weekdays":[1,3]}` + req := httptest.NewRequest("POST", "/api/widget/task/recurrence", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetTaskRecurrence).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + task, err := s.GetNativeTaskByID("plain-1") + if err != nil { + t.Fatal(err) + } + if task.RecurrenceFreq != "weekly" || task.RecurrenceInterval != 2 || task.RecurrenceSeriesID == "" { + t.Errorf("task = %+v, want recurrence set with a generated series id", task) + } +} + +func TestHandleWidgetTaskRecurrence_InvalidFreq_Returns400(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + + body := `{"id":"plain-1","freq":"bogus","interval":1}` + req := httptest.NewRequest("POST", "/api/widget/task/recurrence", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetTaskRecurrence).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for an invalid freq, got %d", w.Code) + } +} + +func TestHandleWidgetTaskNextDate_SetsOverride(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + + if _, err := s.DB().Exec(` + INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_series_id) + VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 'series-1') + `); err != nil { + t.Fatal(err) + } + + body := `{"id":"rec-1","date":"2026-08-01"}` + req := httptest.NewRequest("POST", "/api/widget/task/next-date", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetTaskNextDate).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + task, err := s.GetNativeTaskByID("rec-1") + if err != nil { + t.Fatal(err) + } + if task.NextOccurrenceOverride == nil || task.NextOccurrenceOverride.Format("2006-01-02") != "2026-08-01" { + t.Errorf("NextOccurrenceOverride = %v, want 2026-08-01", task.NextOccurrenceOverride) + } +} + +func TestHandleWidgetTaskNextDate_NonRecurringTask_Returns400(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + + if _, err := s.DB().Exec(`INSERT INTO native_tasks (id, content) VALUES ('plain-1', 'Buy milk')`); err != nil { + t.Fatal(err) + } + + body := `{"id":"plain-1","date":"2026-08-01"}` + req := httptest.NewRequest("POST", "/api/widget/task/next-date", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetTaskNextDate).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for a non-recurring task, got %d", w.Code) + } +} + |
