diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-06-29 00:40:10 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-06-29 00:40:10 +0000 |
| commit | 4b4b3723f2b215a27853ae7cd6d5d0e227a5150f (patch) | |
| tree | c113c0fe1ad7ef41f933a7717eb5156f542502b1 /internal | |
| parent | 6c2df12903bb9e4688866e30a446c69e91283c84 (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>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/handlers/widget.go | 117 | ||||
| -rw-r--r-- | internal/handlers/widget_test.go | 127 |
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 |
