package handlers import ( "encoding/json" "net/http" "strings" "time" "task-dashboard/internal/api" "task-dashboard/internal/config" "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) { if token == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } 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" case models.TimelineItemTypeCard, models.TimelineItemTypeGTask: wi.Type = "task" // not completable via widget API 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 := config.Now() tz := config.GetDisplayTimezone() start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, tz) 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) } }