package handlers import ( "encoding/json" "errors" "net/http" "strings" "time" "task-dashboard/internal/config" "task-dashboard/internal/models" "task-dashboard/internal/store" ) // 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, IsOverdue: item.IsOverdue, URL: item.URL, RecurringEventID: item.RecurringEventID, } 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 == "doot" } // Only populate Start/End for items with a real time. All-day CALENDAR // EVENTS (not undated doot/gtask tasks, which are also flagged IsAllDay // as a "no specific time" fallback -- see TimelineItem.ComputeDaySection) // get Start populated too, using their real event date, so the widget // client can pin them to the top of the correct day's section instead of // losing them in the floating-task hourly-slot packer, which has no // concept of "all day" and can push a slot past the visible grid range // entirely. Tasks keep the existing nil-Start "floating" treatment // regardless of IsAllDay -- only Start is set for them (never End), and // only when they have a real time. if !item.Time.IsZero() && (!item.IsAllDay || item.Type == models.TimelineItemTypeEvent) { t := item.Time wi.Start = &t if !item.IsAllDay { if item.EndTime != nil { wi.End = item.EndTime } else { end := item.Time.Add(time.Hour) wi.End = &end } } } // DueDate is independent of Start/IsAllDay -- doot tasks deliberately // keep Start nil (see the "floating task" doc comment above) so the // client's SlotPacker positions them, but the Android detail popup // still needs to know the real due date to display and reschedule it. if item.Type == models.TimelineItemTypeTask && item.Source == "doot" && !item.Time.IsZero() { due := item.Time wi.DueDate = &due } 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(48 * 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 { if item.DaySection == models.DaySectionToday || item.DaySection == models.DaySectionTomorrow || 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"` } type widgetAddRequest struct { Title string `json:"title"` } type widgetRescheduleRequest struct { ID string `json:"id"` Source string `json:"source"` Date string `json:"date"` // YYYY-MM-DD } // HandleWidgetReschedule updates the due date of a native task. func (h *Handler) HandleWidgetReschedule(w http.ResponseWriter, r *http.Request) { var req widgetRescheduleRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if req.Source != "doot" { http.Error(w, "only doot tasks can be rescheduled via widget", http.StatusBadRequest) return } parsed, err := time.Parse("2006-01-02", req.Date) if err != nil { http.Error(w, "invalid date", http.StatusBadRequest) return } tz := config.GetDisplayTimezone() dueDate := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, tz) if err := h.store.RescheduleNativeTask(req.ID, dueDate); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "task not found", http.StatusNotFound) return } http.Error(w, "failed to reschedule", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } // HandleWidgetComplete proxies a task completion to the source API or native store. func (h *Handler) HandleWidgetComplete(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 } switch req.Source { case "doot": if err := h.store.CompleteNativeTask(req.ID); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { // Surfaced as 404 (not a silent 200) so the caller -- the // Android widget -- can tell "nothing changed" apart from // "it worked", instead of the previous behavior where a // stale/wrong id looked identical to a real completion. http.Error(w, "task not found", http.StatusNotFound) return } http.Error(w, "failed to complete task", http.StatusInternalServerError) return } _ = h.store.SaveCompletedTask("doot", req.ID, "", nil) default: http.Error(w, "source not completable", http.StatusBadRequest) return } w.WriteHeader(http.StatusOK) } // HandleWidgetRecurrence looks up and formats a recurring calendar event's schedule. func (h *Handler) HandleWidgetRecurrence(w http.ResponseWriter, r *http.Request) { recurringEventID := r.URL.Query().Get("recurring_event_id") if recurringEventID == "" { http.Error(w, "recurring_event_id is required", http.StatusBadRequest) return } recurrence, err := h.googleCalendarClient.GetRecurrenceRule(r.Context(), recurringEventID) if err != nil { http.Error(w, "recurring event not found", http.StatusNotFound) return } resp := struct { Recurrence string `json:"recurrence"` }{Recurrence: recurrence} w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } // HandleWidgetAdd creates a new undated native task from the widget's quick-add sheet. func (h *Handler) HandleWidgetAdd(w http.ResponseWriter, r *http.Request) { var req widgetAddRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } title := strings.TrimSpace(req.Title) if title == "" { http.Error(w, "title is required", http.StatusBadRequest) return } task := models.Task{ ID: newID(), Content: title, Priority: 1, } if err := h.store.CreateNativeTask(task); err != nil { http.Error(w, "failed to create task", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) }