summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-06-29 00:40:10 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-06-29 00:40:10 +0000
commit4b4b3723f2b215a27853ae7cd6d5d0e227a5150f (patch)
treec113c0fe1ad7ef41f933a7717eb5156f542502b1
parent6c2df12903bb9e4688866e30a446c69e91283c84 (diff)
feat: add widget HTTP handlers with bearer token auth
Implements WidgetAuthMiddleware (static bearer token), TimelineItemToWidgetItem (conversion helper), HandleWidgetGet (today's items as JSON), and HandleWidgetComplete (proxies task completion to Todoist). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/handlers/widget.go117
-rw-r--r--internal/handlers/widget_test.go127
2 files changed, 244 insertions, 0 deletions
diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go
new file mode 100644
index 0000000..cee2ec8
--- /dev/null
+++ b/internal/handlers/widget.go
@@ -0,0 +1,117 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "task-dashboard/internal/api"
+ "task-dashboard/internal/models"
+)
+
+// WidgetAuthMiddleware validates the static bearer token for widget API endpoints.
+func WidgetAuthMiddleware(token string, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ auth := r.Header.Get("Authorization")
+ if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != token {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// TimelineItemToWidgetItem converts a TimelineItem to a WidgetItem for the widget API.
+// Exported for testability.
+func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem {
+ wi := models.WidgetItem{
+ ID: item.ID,
+ Title: item.Title,
+ Source: item.Source,
+ IsAllDay: item.IsAllDay,
+ URL: item.URL,
+ }
+
+ switch item.Type {
+ case models.TimelineItemTypeEvent:
+ wi.Type = "event"
+ case models.TimelineItemTypeMeal:
+ wi.Type = "event"
+ default:
+ wi.Type = "task"
+ wi.Completable = item.Source == "todoist"
+ }
+
+ // Only populate Start/End for items with a real time (non-all-day, non-zero)
+ if !item.IsAllDay && !item.Time.IsZero() {
+ t := item.Time
+ wi.Start = &t
+ if item.EndTime != nil {
+ wi.End = item.EndTime
+ } else {
+ end := item.Time.Add(time.Hour)
+ wi.End = &end
+ }
+ }
+
+ return wi
+}
+
+// HandleWidgetGet returns today's timeline items as JSON for the Android widget.
+func (h *Handler) HandleWidgetGet(w http.ResponseWriter, r *http.Request) {
+ now := time.Now()
+ start := now.Truncate(24 * time.Hour)
+ end := start.Add(24 * time.Hour)
+
+ items, err := BuildTimeline(r.Context(), h.store, start, end)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ widgetItems := make([]models.WidgetItem, 0, len(items))
+ for _, item := range items {
+ // Only include today's items
+ if item.DaySection == models.DaySectionToday || item.IsOverdue {
+ widgetItems = append(widgetItems, TimelineItemToWidgetItem(item))
+ }
+ }
+
+ resp := models.WidgetResponse{
+ Now: now,
+ Items: widgetItems,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+type widgetCompleteRequest struct {
+ ID string `json:"id"`
+ Source string `json:"source"`
+}
+
+// HandleWidgetComplete proxies a task completion to the source API.
+func HandleWidgetComplete(todoistClient api.TodoistAPI) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req widgetCompleteRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if req.Source != "todoist" {
+ http.Error(w, "source not completable", http.StatusBadRequest)
+ return
+ }
+ if todoistClient == nil {
+ http.Error(w, "todoist not configured", http.StatusServiceUnavailable)
+ return
+ }
+ if err := todoistClient.CompleteTask(r.Context(), req.ID); err != nil {
+ http.Error(w, "failed to complete task", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ }
+}
diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go
new file mode 100644
index 0000000..a82a070
--- /dev/null
+++ b/internal/handlers/widget_test.go
@@ -0,0 +1,127 @@
+package handlers_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "task-dashboard/internal/handlers"
+ "task-dashboard/internal/models"
+)
+
+func TestWidgetAuthMiddleware_NoToken(t *testing.T) {
+ called := false
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })
+ h := handlers.WidgetAuthMiddleware("secret", inner)
+
+ req := httptest.NewRequest("GET", "/api/widget", nil)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", w.Code)
+ }
+ if called {
+ t.Fatal("inner handler should not have been called")
+ }
+}
+
+func TestWidgetAuthMiddleware_WrongToken(t *testing.T) {
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
+ h := handlers.WidgetAuthMiddleware("secret", inner)
+
+ req := httptest.NewRequest("GET", "/api/widget", nil)
+ req.Header.Set("Authorization", "Bearer wrong")
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", w.Code)
+ }
+}
+
+func TestWidgetAuthMiddleware_CorrectToken(t *testing.T) {
+ called := false
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })
+ h := handlers.WidgetAuthMiddleware("secret", inner)
+
+ req := httptest.NewRequest("GET", "/api/widget", nil)
+ req.Header.Set("Authorization", "Bearer secret")
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+
+ if !called {
+ t.Fatal("inner handler should have been called")
+ }
+}
+
+func TestTimelineItemToWidgetItem_Task(t *testing.T) {
+ now := time.Now()
+ item := models.TimelineItem{
+ ID: "abc",
+ Title: "Write notes",
+ Source: "todoist",
+ Type: models.TimelineItemTypeTask,
+ Time: now,
+ IsAllDay: true,
+ URL: "https://todoist.com/task/abc",
+ }
+
+ wi := handlers.TimelineItemToWidgetItem(item)
+
+ if wi.ID != "abc" {
+ t.Errorf("ID: got %q, want %q", wi.ID, "abc")
+ }
+ if wi.Type != "task" {
+ t.Errorf("Type: got %q, want %q", wi.Type, "task")
+ }
+ if !wi.Completable {
+ t.Error("todoist task should be completable")
+ }
+ if wi.Start != nil {
+ t.Error("all-day item should have nil Start")
+ }
+}
+
+func TestTimelineItemToWidgetItem_Event(t *testing.T) {
+ start := time.Now()
+ end := start.Add(time.Hour)
+ item := models.TimelineItem{
+ ID: "cal1",
+ Title: "Team sync",
+ Source: "calendar",
+ Type: models.TimelineItemTypeEvent,
+ Time: start,
+ EndTime: &end,
+ }
+
+ wi := handlers.TimelineItemToWidgetItem(item)
+
+ if wi.Type != "event" {
+ t.Errorf("Type: got %q, want %q", wi.Type, "event")
+ }
+ if wi.Completable {
+ t.Error("calendar event should not be completable")
+ }
+ if wi.Start == nil {
+ t.Error("timed event should have non-nil Start")
+ }
+}
+
+func TestHandleWidgetComplete_NonTodoist(t *testing.T) {
+ h := handlers.HandleWidgetComplete(nil)
+ body := `{"id":"x","source":"calendar"}`
+ req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", w.Code)
+ }
+}
+
+// Ensure json import is used (WidgetResponse roundtrip sanity check).
+var _ = json.Marshal