summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-14 20:35:55 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-16 02:44:52 +0000
commit146034b08446c4bf0099bb41ab5e689d418e6dce (patch)
tree46db3cca04eb3d57b58a5195bbe42d0c193a9043
parentba5205213f6f1995ca4e64b08925bcc6c48b8211 (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.
-rw-r--r--cmd/dashboard/main.go4
-rw-r--r--internal/handlers/widget.go161
-rw-r--r--internal/handlers/widget_test.go197
3 files changed, 362 insertions, 0 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 45a83e2..f78e5b5 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -379,6 +379,10 @@ func main() {
r.With(widgetAuth).Post("/api/widget/complete", h.HandleWidgetComplete)
r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule)
r.With(widgetAuth).Post("/api/widget/add", h.HandleWidgetAdd)
+ r.With(widgetAuth).Get("/api/widget/task", h.HandleWidgetTaskDetail)
+ r.With(widgetAuth).Post("/api/widget/task/update", h.HandleWidgetTaskUpdate)
+ r.With(widgetAuth).Post("/api/widget/task/recurrence", h.HandleWidgetTaskRecurrence)
+ r.With(widgetAuth).Post("/api/widget/task/next-date", h.HandleWidgetTaskNextDate)
} else {
log.Println("WIDGET_TOKEN not set — /api/widget disabled")
}
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)
+ }
+}
+