package handlers import ( "encoding/json" "errors" "log" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "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, ChainPosition: item.ChainPosition, ChainTotal: item.ChainTotal, BucketState: item.BucketState, } 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)) } } budgetStatus, err := h.computeBudgetStatus(now) if err != nil { log.Printf("Warning: failed to compute budget status: %v", err) } resp := models.WidgetResponse{ Now: now, Items: widgetItems, BudgetStatus: budgetStatus, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } // computeBudgetStatus returns budget status for "today" and a rolling // 7-day "week" window starting today, or nil if no budget-tracked task // falls in the week window (per the spec: the field is absent unless // budget-tracked tasks exist, so an unconfigured user sees no new UI). func (h *Handler) computeBudgetStatus(now time.Time) (*models.BudgetStatus, error) { tz := config.GetDisplayTimezone() todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, tz) todayEnd := todayStart.Add(24 * time.Hour) weekEnd := todayStart.AddDate(0, 0, 7) blocks, err := h.store.GetAvailabilityBlocks() if err != nil { return nil, err } trackedProjects, err := h.store.GetBudgetTrackedProjectIDs() if err != nil { return nil, err } trackedLabels, err := h.store.GetBudgetTrackedLabelNames() if err != nil { return nil, err } events, err := h.store.GetCalendarEventsByDateRange(todayStart, weekEnd) if err != nil { return nil, err } overdue, err := h.store.GetOverdueNativeTasks(todayStart) if err != nil { return nil, err } weekTasks, err := h.store.GetNativeTasksByDateRange(todayStart, weekEnd) if err != nil { return nil, err } allTasks := append(append([]models.Task{}, overdue...), weekTasks...) hasTracked := false for _, task := range allTasks { if isBudgetTracked(task, trackedProjects, trackedLabels) && !task.Completed { hasTracked = true break } } if !hasTracked { return nil, nil } today := ComputeBudgetPeriod(blocks, events, allTasks, trackedProjects, trackedLabels, todayStart, todayEnd) week := ComputeBudgetPeriod(blocks, events, allTasks, trackedProjects, trackedLabels, todayStart, weekEnd) return &models.BudgetStatus{Today: today, Week: week}, nil } 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 } if errors.Is(err, store.ErrChainTaskLocked) { http.Error(w, "task is locked in its chain", http.StatusBadRequest) 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) } type widgetAddResponse struct { ID string `json:"id"` } // HandleWidgetAdd creates a new undated native task from the widget's quick-add // sheet and returns its id, so the client can follow up with the same // project/labels/recurrence/due-date setter calls the edit popup uses. 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.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(widgetAddResponse{ID: task.ID}) } 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"` EstimatedMinutes int `json:"estimated_minutes,omitempty"` SuggestedEstimateMinutes *int `json:"suggested_estimate_minutes,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 } } resp.EstimatedMinutes = task.EstimatedMinutes if task.EstimatedMinutes == 0 { if task.ProjectID != "" { if avg, ok, err := h.store.AverageEstimateForProject(task.ProjectID); err == nil && ok { resp.SuggestedEstimateMinutes = &avg } } if resp.SuggestedEstimateMinutes == nil { for _, label := range task.Labels { if avg, ok, err := h.store.AverageEstimateForLabel(label); err == nil && ok { resp.SuggestedEstimateMinutes = &avg break } } } } 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) } type availabilityBlockResponse struct { ID string `json:"id"` Weekday int `json:"weekday"` StartTime string `json:"start_time"` EndTime string `json:"end_time"` Label string `json:"label"` } func availabilityBlockToResponse(b models.AvailabilityBlock) availabilityBlockResponse { return availabilityBlockResponse{ID: b.ID, Weekday: b.Weekday, StartTime: b.StartTime, EndTime: b.EndTime, Label: b.Label} } // HandleWidgetAvailabilityGet returns every configured availability block. func (h *Handler) HandleWidgetAvailabilityGet(w http.ResponseWriter, r *http.Request) { blocks, err := h.store.GetAvailabilityBlocks() if err != nil { http.Error(w, "failed to load availability", http.StatusInternalServerError) return } resp := make([]availabilityBlockResponse, 0, len(blocks)) for _, b := range blocks { resp = append(resp, availabilityBlockToResponse(b)) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } type availabilityCreateRequest struct { Weekday int `json:"weekday"` StartTime string `json:"start_time"` EndTime string `json:"end_time"` Label string `json:"label"` } // HandleWidgetAvailabilityCreate creates a new weekly availability block. func (h *Handler) HandleWidgetAvailabilityCreate(w http.ResponseWriter, r *http.Request) { var req availabilityCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if req.Weekday < 0 || req.Weekday > 6 || req.StartTime == "" || req.EndTime == "" { http.Error(w, "weekday (0-6), start_time, and end_time are required", http.StatusBadRequest) return } block, err := h.store.CreateAvailabilityBlock(req.Weekday, req.StartTime, req.EndTime, req.Label) if err != nil { http.Error(w, "failed to create availability block", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(availabilityBlockToResponse(*block)) } type availabilityDeleteRequest struct { ID string `json:"id"` } // HandleWidgetAvailabilityDelete deletes an availability block. func (h *Handler) HandleWidgetAvailabilityDelete(w http.ResponseWriter, r *http.Request) { var req availabilityDeleteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if err := h.store.DeleteAvailabilityBlock(req.ID); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "availability block not found", http.StatusNotFound) return } http.Error(w, "failed to delete availability block", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } type taskEstimateRequest struct { ID string `json:"id"` EstimatedMinutes int `json:"estimated_minutes"` } // HandleWidgetTaskEstimate sets a task's estimated duration in minutes. func (h *Handler) HandleWidgetTaskEstimate(w http.ResponseWriter, r *http.Request) { var req taskEstimateRequest 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.SetTaskEstimate(req.ID, req.EstimatedMinutes); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "task not found", http.StatusNotFound) return } http.Error(w, "failed to set estimate", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } type projectBudgetTrackedRequest struct { ID string `json:"id"` Tracked bool `json:"tracked"` } // HandleWidgetProjectsBudgetTracked opts a project in or out of budget tracking. func (h *Handler) HandleWidgetProjectsBudgetTracked(w http.ResponseWriter, r *http.Request) { var req projectBudgetTrackedRequest 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.SetProjectBudgetTracked(req.ID, req.Tracked); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "project not found", http.StatusNotFound) return } http.Error(w, "failed to update project", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } type labelBudgetTrackedRequest struct { Name string `json:"name"` Tracked bool `json:"tracked"` } // HandleWidgetLabelsBudgetTracked opts a label in or out of budget tracking. func (h *Handler) HandleWidgetLabelsBudgetTracked(w http.ResponseWriter, r *http.Request) { var req labelBudgetTrackedRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if req.Name == "" { http.Error(w, "name is required", http.StatusBadRequest) return } if err := h.store.SetLabelBudgetTracked(req.Name, req.Tracked); err != nil { http.Error(w, "failed to update label", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } type chainCreateRequest struct { Name string `json:"name"` Tasks []models.ChainTaskInput `json:"tasks"` } type chainCreateResponse struct { ID string `json:"id"` } // HandleWidgetChainsCreate creates a linear task chain: a backing project // plus one native_tasks row per entry in req.Tasks (content required; // description and priority optional, priority defaulting to 1), position 0 // unlocked and due now, the rest locked with no due date. func (h *Handler) HandleWidgetChainsCreate(w http.ResponseWriter, r *http.Request) { var req chainCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if strings.TrimSpace(req.Name) == "" || len(req.Tasks) == 0 { http.Error(w, "name and at least one task are required", http.StatusBadRequest) return } for _, t := range req.Tasks { if strings.TrimSpace(t.Content) == "" { http.Error(w, "every task requires non-empty content", http.StatusBadRequest) return } } chain, err := h.store.CreateChain(req.Name, req.Tasks) if err != nil { http.Error(w, "failed to create chain", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(chainCreateResponse{ID: chain.ID}) } // handleWidgetChainSetStatus is the shared body for pause/resume/abandon -- // each just sets a different status string on the chain named by the {id} // URL param. func (h *Handler) handleWidgetChainSetStatus(w http.ResponseWriter, r *http.Request, status string) { id := chi.URLParam(r, "id") if err := h.store.SetChainStatus(id, status); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "chain not found", http.StatusNotFound) return } http.Error(w, "failed to update chain", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } // HandleWidgetChainsPause pauses a chain: the currently-unlocked task stays // actionable, but completing it will not auto-advance until resumed. func (h *Handler) HandleWidgetChainsPause(w http.ResponseWriter, r *http.Request) { h.handleWidgetChainSetStatus(w, r, "paused") } // HandleWidgetChainsResume reactivates a paused chain. func (h *Handler) HandleWidgetChainsResume(w http.ResponseWriter, r *http.Request) { h.handleWidgetChainSetStatus(w, r, "active") } // HandleWidgetChainsAbandon marks a chain abandoned -- a terminal state // distinguishable from "completed" in queries/reporting. func (h *Handler) HandleWidgetChainsAbandon(w http.ResponseWriter, r *http.Request) { h.handleWidgetChainSetStatus(w, r, "abandoned") } type chainGetResponse struct { Chain models.Chain `json:"chain"` Tasks []models.Task `json:"tasks"` } // HandleWidgetChainGet returns the full ordered checklist for a chain -- // locked and unlocked tasks both, per the design's "visible in the tasks // list" requirement met via this dedicated surface. func (h *Handler) HandleWidgetChainGet(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") chain, err := h.store.GetChain(id) if err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "chain not found", http.StatusNotFound) return } http.Error(w, "internal error", http.StatusInternalServerError) return } tasks, err := h.store.GetChainTasks(id) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(chainGetResponse{Chain: *chain, Tasks: tasks}) } type bucketCreateRequest struct { Name string `json:"name"` CycleDays int `json:"cycle_days"` PickN int `json:"pick_n"` } // HandleWidgetBucketsGet returns every maintenance bucket. func (h *Handler) HandleWidgetBucketsGet(w http.ResponseWriter, r *http.Request) { buckets, err := h.store.GetBuckets() if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(buckets) } // HandleWidgetBucketsCreate creates a new maintenance bucket. func (h *Handler) HandleWidgetBucketsCreate(w http.ResponseWriter, r *http.Request) { var req bucketCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if strings.TrimSpace(req.Name) == "" || req.CycleDays <= 0 || req.PickN <= 0 { http.Error(w, "name, a positive cycle_days, and a positive pick_n are required", http.StatusBadRequest) return } bucket, err := h.store.CreateBucket(req.Name, req.CycleDays, req.PickN) if err != nil { http.Error(w, "failed to create bucket", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(bucket) } type bucketItemRequest struct { TaskID string `json:"task_id"` } // HandleWidgetBucketItemsAdd assigns an existing task to a bucket's pool. func (h *Handler) HandleWidgetBucketItemsAdd(w http.ResponseWriter, r *http.Request) { bucketID := chi.URLParam(r, "id") var req bucketItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if err := h.store.AddBucketItem(bucketID, req.TaskID); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "task not found", http.StatusNotFound) return } http.Error(w, "failed to add bucket item", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } // HandleWidgetBucketItemsRemove clears a task's bucket membership. func (h *Handler) HandleWidgetBucketItemsRemove(w http.ResponseWriter, r *http.Request) { var req bucketItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if err := h.store.RemoveBucketItem(req.TaskID); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "task not found", http.StatusNotFound) return } http.Error(w, "failed to remove bucket item", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } type taskDeferRequest struct { ID string `json:"id"` } // HandleWidgetTaskDefer returns an active bucket item to its pool without // crediting it as done -- distinct from Complete -- and triggers a fresh // selection to backfill the freed slot. func (h *Handler) HandleWidgetTaskDefer(w http.ResponseWriter, r *http.Request) { var req taskDeferRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if err := h.store.DeferNativeTask(req.ID); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "task not found or not an active bucket item", http.StatusNotFound) return } http.Error(w, "failed to defer task", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) }