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: wi.Type = "task" wi.Completable = true case models.TimelineItemTypeGTask: wi.Type = "task" wi.Completable = true 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.Type == models.TimelineItemTypeEvent { // Events always forward End when known, even when IsAllDay -- // a multi-day all-day event (e.g. a 3-day conference) needs its // End date reaching the client so it can detect the span and // render "starts"/"ends"/spans-through labels per rendered day. if item.EndTime != nil { wi.End = item.EndTime } } else 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 } if item.ProjectColor != "" { wi.ProjectColor = &item.ProjectColor } 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) } // findGoogleTask looks up a cached Google Task by ID. func (h *Handler) findGoogleTask(id string) (models.GoogleTask, bool) { gTasks, err := h.store.GetGoogleTasks() if err != nil { return models.GoogleTask{}, false } for _, t := range gTasks { if t.ID == id { return t, true } } return models.GoogleTask{}, false } // findCard looks up a cached Trello card by ID. func (h *Handler) findCard(id string) (models.Card, bool) { boards, err := h.store.GetBoards() if err != nil { return models.Card{}, false } for _, b := range boards { for _, c := range b.Cards { if c.ID == id { return c, true } } } return models.Card{}, false } type widgetDetailResponse struct { Title string `json:"title"` Description string `json:"description"` Editable bool `json:"editable"` } // HandleWidgetDetail returns a task's title/description for the widget's edit popup. func (h *Handler) HandleWidgetDetail(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") source := r.URL.Query().Get("source") if id == "" || source == "" { http.Error(w, "missing id or source", http.StatusBadRequest) return } var resp widgetDetailResponse switch source { case "doot": tasks, err := h.store.GetNativeTasks() if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } for _, t := range tasks { if t.ID == id { resp = widgetDetailResponse{Title: t.Content, Description: t.Description, Editable: true} break } } case "gtasks": if t, ok := h.findGoogleTask(id); ok { resp = widgetDetailResponse{Title: t.Title, Description: t.Notes, Editable: h.googleTasksClient != nil} } case "trello": if c, ok := h.findCard(id); ok { resp = widgetDetailResponse{Title: c.Name, Description: c.Description, Editable: h.trelloClient != nil} } default: http.Error(w, "unsupported source", http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } type widgetUpdateRequest struct { ID string `json:"id"` Source string `json:"source"` Description string `json:"description"` } // HandleWidgetUpdate saves an edited description from the widget's edit popup. func (h *Handler) HandleWidgetUpdate(w http.ResponseWriter, r *http.Request) { var req widgetUpdateRequest 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.UpdateNativeTaskDescription(req.ID, req.Description); err != nil { http.Error(w, "failed to update task", http.StatusInternalServerError) return } case "gtasks": if h.googleTasksClient == nil { http.Error(w, "google tasks not configured", http.StatusServiceUnavailable) return } t, ok := h.findGoogleTask(req.ID) if !ok { http.Error(w, "task not found", http.StatusNotFound) return } if err := h.googleTasksClient.UpdateTaskNotes(r.Context(), t.ListID, req.ID, req.Description); err != nil { http.Error(w, "failed to update task", http.StatusInternalServerError) return } _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks) case "trello": if h.trelloClient == nil { http.Error(w, "trello not configured", http.StatusServiceUnavailable) return } if err := h.trelloClient.UpdateCard(r.Context(), req.ID, map[string]interface{}{"desc": req.Description}); err != nil { http.Error(w, "failed to update task", http.StatusInternalServerError) return } _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) default: http.Error(w, "source not editable", http.StatusBadRequest) 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) case "gtasks": if h.googleTasksClient == nil { http.Error(w, "google tasks not configured", http.StatusServiceUnavailable) return } t, ok := h.findGoogleTask(req.ID) if !ok { http.Error(w, "task not found", http.StatusNotFound) return } if err := h.googleTasksClient.CompleteTask(r.Context(), t.ListID, req.ID); err != nil { http.Error(w, "failed to complete task", http.StatusInternalServerError) return } _ = h.store.SaveCompletedTask("gtasks", req.ID, t.Title, t.DueDate) _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks) case "trello": if h.trelloClient == nil { http.Error(w, "trello not configured", http.StatusServiceUnavailable) return } c, ok := h.findCard(req.ID) if !ok { http.Error(w, "task not found", http.StatusNotFound) return } if err := h.trelloClient.UpdateCard(r.Context(), req.ID, map[string]interface{}{"closed": true}); err != nil { http.Error(w, "failed to complete task", http.StatusInternalServerError) return } _ = h.store.SaveCompletedTask("trello", req.ID, c.Name, c.DueDate) _ = h.store.DeleteCard(req.ID) default: http.Error(w, "source not completable", http.StatusBadRequest) return } w.WriteHeader(http.StatusOK) } // 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) } type recurrenceResponse struct { Freq string `json:"freq"` Interval int `json:"interval"` Weekdays []int `json:"weekdays,omitempty"` } type projectResponse struct { ID string `json:"id"` Name string `json:"name"` Color string `json:"color"` CreatedAt time.Time `json:"created_at"` } func projectToResponse(p models.Project) projectResponse { return projectResponse{ID: p.ID, Name: p.Name, Color: p.Color, CreatedAt: p.CreatedAt} } // HandleWidgetProjectsGet returns all non-archived projects. func (h *Handler) HandleWidgetProjectsGet(w http.ResponseWriter, r *http.Request) { projects, err := h.store.GetProjects() if err != nil { http.Error(w, "failed to load projects", http.StatusInternalServerError) return } resp := make([]projectResponse, 0, len(projects)) for _, p := range projects { resp = append(resp, projectToResponse(p)) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } type projectCreateRequest struct { Name string `json:"name"` Color string `json:"color"` } // HandleWidgetProjectsCreate creates a new project. func (h *Handler) HandleWidgetProjectsCreate(w http.ResponseWriter, r *http.Request) { var req projectCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if req.Name == "" || req.Color == "" { http.Error(w, "name and color are required", http.StatusBadRequest) return } project, err := h.store.CreateProject(req.Name, req.Color) if err != nil { http.Error(w, "failed to create project", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(projectToResponse(*project)) } type taskProjectRequest struct { ID string `json:"id"` ProjectID string `json:"project_id"` } // HandleWidgetTaskProject sets or clears a task's project. func (h *Handler) HandleWidgetTaskProject(w http.ResponseWriter, r *http.Request) { var req taskProjectRequest 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 err := h.store.SetTaskProject(req.ID, req.ProjectID); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "task not found", http.StatusNotFound) return } http.Error(w, "failed to set project", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } type taskLabelsRequest struct { ID string `json:"id"` Labels []string `json:"labels"` } // HandleWidgetTaskLabels replaces a task's label set. func (h *Handler) HandleWidgetTaskLabels(w http.ResponseWriter, r *http.Request) { var req taskLabelsRequest 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 err := h.store.SetTaskLabels(req.ID, req.Labels); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "task not found", http.StatusNotFound) return } http.Error(w, "failed to set labels", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } type labelColorResponse struct { Name string `json:"name"` Color string `json:"color"` } // HandleWidgetLabelsGet returns every label that has an assigned color. func (h *Handler) HandleWidgetLabelsGet(w http.ResponseWriter, r *http.Request) { colors, err := h.store.GetLabelColors() if err != nil { http.Error(w, "failed to load label colors", http.StatusInternalServerError) return } resp := make([]labelColorResponse, 0, len(colors)) for _, c := range colors { resp = append(resp, labelColorResponse{Name: c.Name, Color: c.Color}) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } type labelColorSetRequest struct { Name string `json:"name"` Color string `json:"color"` } // HandleWidgetLabelsColorSet assigns a label's display color. func (h *Handler) HandleWidgetLabelsColorSet(w http.ResponseWriter, r *http.Request) { var req labelColorSetRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if req.Name == "" || req.Color == "" { http.Error(w, "name and color are required", http.StatusBadRequest) return } if err := h.store.SetLabelColor(req.Name, req.Color); err != nil { http.Error(w, "failed to set label color", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } 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"` Project *projectResponse `json:"project,omitempty"` Labels []string `json:"labels,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, Labels: task.Labels, } if task.ProjectID != "" { if project, err := h.store.GetProjectByID(task.ProjectID); err == nil { resp.Project = &projectResponse{ID: project.ID, Name: project.Name, Color: project.Color} } } 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 { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "task not found", http.StatusNotFound) return } 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) }