From 465093343ddd398ce5f6377fc9c472d8251c618b Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Fri, 23 Jan 2026 21:37:18 -1000 Subject: Refactor: reduce code duplication with shared abstractions - Add BaseClient HTTP abstraction (internal/api/http.go) to eliminate duplicated HTTP boilerplate across Todoist, Trello, and PlanToEat clients - Add response helpers (internal/handlers/response.go) for JSON/HTML responses - Add generic cache wrapper (internal/handlers/cache.go) using Go generics - Consolidate HandleCompleteAtom/HandleUncompleteAtom into handleAtomToggle - Merge TabsHandler into Handler, delete tabs.go - Extract sortTasksByUrgency and filterAndSortTrelloTasks helpers - Update tests to work with new BaseClient structure Co-Authored-By: Claude Opus 4.5 --- internal/handlers/handlers.go | 1029 +++++++++++++++++++++-------------------- 1 file changed, 529 insertions(+), 500 deletions(-) (limited to 'internal/handlers/handlers.go') diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index c364188..126eef1 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -2,7 +2,6 @@ package handlers import ( "context" - "encoding/json" "fmt" "html/template" "log" @@ -105,30 +104,22 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { // HandleRefresh forces a refresh of all data func (h *Handler) HandleRefresh(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Force refresh by passing true - data, err := h.aggregateData(ctx, true) + data, err := h.aggregateData(r.Context(), true) if err != nil { - http.Error(w, "Failed to refresh data", http.StatusInternalServerError) - log.Printf("Error refreshing data: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to refresh data", err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(data) + JSONResponse(w, data) } // HandleGetTasks returns tasks as JSON func (h *Handler) HandleGetTasks(w http.ResponseWriter, r *http.Request) { tasks, err := h.store.GetTasks() if err != nil { - http.Error(w, "Failed to get tasks", http.StatusInternalServerError) + JSONError(w, http.StatusInternalServerError, "Failed to get tasks", err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tasks) + JSONResponse(w, tasks) } // HandleGetMeals returns meals as JSON @@ -138,62 +129,43 @@ func (h *Handler) HandleGetMeals(w http.ResponseWriter, r *http.Request) { meals, err := h.store.GetMeals(startDate, endDate) if err != nil { - http.Error(w, "Failed to get meals", http.StatusInternalServerError) + JSONError(w, http.StatusInternalServerError, "Failed to get meals", err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(meals) + JSONResponse(w, meals) } // HandleGetBoards returns Trello boards with cards as JSON func (h *Handler) HandleGetBoards(w http.ResponseWriter, r *http.Request) { boards, err := h.store.GetBoards() if err != nil { - http.Error(w, "Failed to get boards", http.StatusInternalServerError) + JSONError(w, http.StatusInternalServerError, "Failed to get boards", err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(boards) + JSONResponse(w, boards) } // HandleTasksTab renders the tasks tab content (Trello + Todoist + PlanToEat) func (h *Handler) HandleTasksTab(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - data, err := h.aggregateData(ctx, false) + data, err := h.aggregateData(r.Context(), false) if err != nil { - http.Error(w, "Failed to load tasks", http.StatusInternalServerError) - log.Printf("Error loading tasks tab: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to load tasks", err) return } - - if err := h.templates.ExecuteTemplate(w, "tasks-tab", data); err != nil { - http.Error(w, "Failed to render template", http.StatusInternalServerError) - log.Printf("Error rendering tasks tab: %v", err) - } + HTMLResponse(w, h.templates, "tasks-tab", data) } // HandleRefreshTab refreshes and re-renders the specified tab func (h *Handler) HandleRefreshTab(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Force refresh - data, err := h.aggregateData(ctx, true) + data, err := h.aggregateData(r.Context(), true) if err != nil { - http.Error(w, "Failed to refresh", http.StatusInternalServerError) - log.Printf("Error refreshing tab: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to refresh", err) return } - - if err := h.templates.ExecuteTemplate(w, "tasks-tab", data); err != nil { - http.Error(w, "Failed to render template", http.StatusInternalServerError) - log.Printf("Error rendering refreshed tab: %v", err) - } + HTMLResponse(w, h.templates, "tasks-tab", data) } -// aggregateData fetches and caches data from all sources +// aggregateData fetches and caches data from all sources concurrently func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models.DashboardData, error) { data := &models.DashboardData{ LastUpdated: time.Now(), @@ -203,169 +175,132 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models var wg sync.WaitGroup var mu sync.Mutex - // Fetch Trello boards (PRIORITY - most important) - wg.Add(1) - go func() { - defer wg.Done() - select { - case <-ctx.Done(): - return - default: - } + // Helper to run fetch in goroutine with error collection + fetch := func(name string, fn func() error) { + wg.Add(1) + go func() { + defer wg.Done() + select { + case <-ctx.Done(): + return + default: + } + if err := fn(); err != nil { + mu.Lock() + data.Errors = append(data.Errors, name+": "+err.Error()) + mu.Unlock() + } + }() + } + + fetch("Trello", func() error { boards, err := h.fetchBoards(ctx, forceRefresh) - mu.Lock() - defer mu.Unlock() - if err != nil { - data.Errors = append(data.Errors, "Trello: "+err.Error()) - } else { + if err == nil { + mu.Lock() data.Boards = boards + mu.Unlock() } - }() - - // Fetch Todoist tasks - wg.Add(1) - go func() { - defer wg.Done() - select { - case <-ctx.Done(): - return - default: - } - tasks, err := h.fetchTasks(ctx, forceRefresh) - mu.Lock() - defer mu.Unlock() - if err != nil { - data.Errors = append(data.Errors, "Todoist: "+err.Error()) - } else { - // Sort tasks: earliest due date first, nil last, then by priority (descending) - sort.Slice(tasks, func(i, j int) bool { - // Handle nil due dates (push to end) - if tasks[i].DueDate == nil && tasks[j].DueDate != nil { - return false - } - if tasks[i].DueDate != nil && tasks[j].DueDate == nil { - return true - } - - // Both have due dates, sort by date - if tasks[i].DueDate != nil && tasks[j].DueDate != nil { - if !tasks[i].DueDate.Equal(*tasks[j].DueDate) { - return tasks[i].DueDate.Before(*tasks[j].DueDate) - } - } + return err + }) - // Same due date (or both nil), sort by priority (descending) - return tasks[i].Priority > tasks[j].Priority - }) + fetch("Todoist", func() error { + tasks, err := h.fetchTasks(ctx, forceRefresh) + if err == nil { + sortTasksByUrgency(tasks) + mu.Lock() data.Tasks = tasks + mu.Unlock() } - }() - - // Fetch Todoist projects - wg.Add(1) - go func() { - defer wg.Done() - select { - case <-ctx.Done(): - return - default: - } + return err + }) + + fetch("Projects", func() error { projects, err := h.todoistClient.GetProjects(ctx) - mu.Lock() - defer mu.Unlock() - if err != nil { - log.Printf("Failed to fetch projects: %v", err) - } else { + if err == nil { + mu.Lock() data.Projects = projects + mu.Unlock() } - }() + return err + }) - // Fetch PlanToEat meals (if configured) if h.planToEatClient != nil { - wg.Add(1) - go func() { - defer wg.Done() - select { - case <-ctx.Done(): - return - default: - } + fetch("PlanToEat", func() error { meals, err := h.fetchMeals(ctx, forceRefresh) - mu.Lock() - defer mu.Unlock() - if err != nil { - data.Errors = append(data.Errors, "PlanToEat: "+err.Error()) - } else { + if err == nil { + mu.Lock() data.Meals = meals + mu.Unlock() } - }() + return err + }) } - // Fetch Google Calendar events (if configured) if h.googleCalendarClient != nil { - wg.Add(1) - go func() { - defer wg.Done() - select { - case <-ctx.Done(): - return - default: - } + fetch("Google Calendar", func() error { events, err := h.googleCalendarClient.GetUpcomingEvents(ctx, 10) - mu.Lock() - defer mu.Unlock() - if err != nil { - data.Errors = append(data.Errors, "Google Calendar: "+err.Error()) - } else { + if err == nil { + mu.Lock() data.Events = events + mu.Unlock() } - }() + return err + }) } wg.Wait() - // Filter Trello cards into tasks based on heuristic - var trelloTasks []models.Card - for _, board := range data.Boards { + // Extract and sort Trello tasks + data.TrelloTasks = filterAndSortTrelloTasks(data.Boards) + + return data, nil +} + +// sortTasksByUrgency sorts tasks by due date then priority +func sortTasksByUrgency(tasks []models.Task) { + sort.Slice(tasks, func(i, j int) bool { + if tasks[i].DueDate == nil && tasks[j].DueDate != nil { + return false + } + if tasks[i].DueDate != nil && tasks[j].DueDate == nil { + return true + } + if tasks[i].DueDate != nil && tasks[j].DueDate != nil { + if !tasks[i].DueDate.Equal(*tasks[j].DueDate) { + return tasks[i].DueDate.Before(*tasks[j].DueDate) + } + } + return tasks[i].Priority > tasks[j].Priority + }) +} + +// filterAndSortTrelloTasks extracts task-like cards from boards +func filterAndSortTrelloTasks(boards []models.Board) []models.Card { + var tasks []models.Card + for _, board := range boards { for _, card := range board.Cards { - listNameLower := strings.ToLower(card.ListName) - isTask := card.DueDate != nil || - strings.Contains(listNameLower, "todo") || - strings.Contains(listNameLower, "doing") || - strings.Contains(listNameLower, "progress") || - strings.Contains(listNameLower, "task") - - if isTask { - trelloTasks = append(trelloTasks, card) + if card.DueDate != nil || isActionableList(card.ListName) { + tasks = append(tasks, card) } } } - // Sort trelloTasks: earliest due date first, nil last, then by board name - sort.Slice(trelloTasks, func(i, j int) bool { - // Both have due dates: compare dates - if trelloTasks[i].DueDate != nil && trelloTasks[j].DueDate != nil { - if !trelloTasks[i].DueDate.Equal(*trelloTasks[j].DueDate) { - return trelloTasks[i].DueDate.Before(*trelloTasks[j].DueDate) + sort.Slice(tasks, func(i, j int) bool { + if tasks[i].DueDate != nil && tasks[j].DueDate != nil { + if !tasks[i].DueDate.Equal(*tasks[j].DueDate) { + return tasks[i].DueDate.Before(*tasks[j].DueDate) } - // Same due date, fall through to board name comparison } - - // Only one has due date: that one comes first - if trelloTasks[i].DueDate != nil && trelloTasks[j].DueDate == nil { + if tasks[i].DueDate != nil && tasks[j].DueDate == nil { return true } - if trelloTasks[i].DueDate == nil && trelloTasks[j].DueDate != nil { + if tasks[i].DueDate == nil && tasks[j].DueDate != nil { return false } - - // Both nil or same due date: sort by board name - return trelloTasks[i].BoardName < trelloTasks[j].BoardName + return tasks[i].BoardName < tasks[j].BoardName }) - data.TrelloTasks = trelloTasks - - return data, nil + return tasks } // fetchTasks fetches tasks from cache or API using incremental sync @@ -451,134 +386,74 @@ func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.T // convertSyncItemToTask converts a sync item to a Task model func (h *Handler) convertSyncItemToTask(item api.SyncItemResponse, projectMap map[string]string) models.Task { - task := models.Task{ + // Use the ConvertSyncItemsToTasks helper for single item conversion + items := api.ConvertSyncItemsToTasks([]api.SyncItemResponse{item}, projectMap) + if len(items) > 0 { + return items[0] + } + // Fallback for completed/deleted items (shouldn't happen in practice) + return models.Task{ ID: item.ID, Content: item.Content, Description: item.Description, ProjectID: item.ProjectID, ProjectName: projectMap[item.ProjectID], Priority: item.Priority, - Completed: false, + Completed: item.IsCompleted, Labels: item.Labels, URL: fmt.Sprintf("https://todoist.com/app/task/%s", item.ID), } - - if item.AddedAt != "" { - if createdAt, err := time.Parse(time.RFC3339, item.AddedAt); err == nil { - task.CreatedAt = createdAt - } - } - - if item.Due != nil { - var dueDate time.Time - var err error - if item.Due.Datetime != "" { - dueDate, err = time.Parse(time.RFC3339, item.Due.Datetime) - } else if item.Due.Date != "" { - dueDate, err = time.Parse("2006-01-02", item.Due.Date) - } - if err == nil { - task.DueDate = &dueDate - } - } - - return task } // fetchMeals fetches meals from cache or API func (h *Handler) fetchMeals(ctx context.Context, forceRefresh bool) ([]models.Meal, error) { - cacheKey := store.CacheKeyPlanToEatMeals - - // Check cache validity - if !forceRefresh { - valid, err := h.store.IsCacheValid(cacheKey) - if err == nil && valid { - startDate := time.Now() - endDate := startDate.AddDate(0, 0, 7) - return h.store.GetMeals(startDate, endDate) - } - } - - // Fetch from API - meals, err := h.planToEatClient.GetUpcomingMeals(ctx, 7) - if err != nil { - // Try to return cached data even if stale - startDate := time.Now() - endDate := startDate.AddDate(0, 0, 7) - cachedMeals, cacheErr := h.store.GetMeals(startDate, endDate) - if cacheErr == nil && len(cachedMeals) > 0 { - return cachedMeals, nil - } - return nil, err - } - - // Save to cache - if err := h.store.SaveMeals(meals); err != nil { - log.Printf("Failed to save meals to cache: %v", err) - } + startDate := time.Now() + endDate := startDate.AddDate(0, 0, 7) - // Update cache metadata - if err := h.store.UpdateCacheMetadata(cacheKey, h.config.CacheTTLMinutes); err != nil { - log.Printf("Failed to update cache metadata: %v", err) + fetcher := &CacheFetcher[models.Meal]{ + Store: h.store, + CacheKey: store.CacheKeyPlanToEatMeals, + TTLMinutes: h.config.CacheTTLMinutes, + Fetch: func(ctx context.Context) ([]models.Meal, error) { return h.planToEatClient.GetUpcomingMeals(ctx, 7) }, + GetFromCache: func() ([]models.Meal, error) { return h.store.GetMeals(startDate, endDate) }, + SaveToCache: h.store.SaveMeals, } - - return meals, nil + return fetcher.FetchWithCache(ctx, forceRefresh) } // fetchBoards fetches Trello boards from cache or API func (h *Handler) fetchBoards(ctx context.Context, forceRefresh bool) ([]models.Board, error) { - cacheKey := store.CacheKeyTrelloBoards - - // Check cache validity - if !forceRefresh { - valid, err := h.store.IsCacheValid(cacheKey) - if err == nil && valid { - return h.store.GetBoards() - } - } - - // Fetch from API - boards, err := h.trelloClient.GetBoardsWithCards(ctx) - if err != nil { - // Try to return cached data even if stale - cachedBoards, cacheErr := h.store.GetBoards() - if cacheErr == nil && len(cachedBoards) > 0 { - return cachedBoards, nil - } - return nil, err - } - - // Debug: log what we got from API - totalCards := 0 - for _, b := range boards { - totalCards += len(b.Cards) - if len(b.Cards) > 0 { - log.Printf("Trello API: Board %q has %d cards", b.Name, len(b.Cards)) - } - } - log.Printf("Trello API: Fetched %d boards with %d total cards", len(boards), totalCards) - - // Save to cache - if err := h.store.SaveBoards(boards); err != nil { - log.Printf("Failed to save boards to cache: %v", err) - } - - // Update cache metadata - if err := h.store.UpdateCacheMetadata(cacheKey, h.config.CacheTTLMinutes); err != nil { - log.Printf("Failed to update cache metadata: %v", err) + fetcher := &CacheFetcher[models.Board]{ + Store: h.store, + CacheKey: store.CacheKeyTrelloBoards, + TTLMinutes: h.config.CacheTTLMinutes, + Fetch: func(ctx context.Context) ([]models.Board, error) { + boards, err := h.trelloClient.GetBoardsWithCards(ctx) + if err == nil { + // Debug logging + totalCards := 0 + for _, b := range boards { + totalCards += len(b.Cards) + if len(b.Cards) > 0 { + log.Printf("Trello API: Board %q has %d cards", b.Name, len(b.Cards)) + } + } + log.Printf("Trello API: Fetched %d boards with %d total cards", len(boards), totalCards) + } + return boards, err + }, + GetFromCache: h.store.GetBoards, + SaveToCache: h.store.SaveBoards, } - - return boards, nil + return fetcher.FetchWithCache(ctx, forceRefresh) } // HandleCreateCard creates a new Trello card func (h *Handler) HandleCreateCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Parse form data if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) - log.Printf("Error parsing form: %v", err) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } @@ -587,27 +462,21 @@ func (h *Handler) HandleCreateCard(w http.ResponseWriter, r *http.Request) { name := r.FormValue("name") if boardID == "" || listID == "" || name == "" { - http.Error(w, "Missing required fields", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing required fields", nil) return } - // Create the card - _, err := h.trelloClient.CreateCard(ctx, listID, name, "", nil) - if err != nil { - http.Error(w, "Failed to create card", http.StatusInternalServerError) - log.Printf("Error creating card: %v", err) + if _, err := h.trelloClient.CreateCard(ctx, listID, name, "", nil); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to create card", err) return } - // Force refresh to get updated data data, err := h.aggregateData(ctx, true) if err != nil { - http.Error(w, "Failed to refresh data", http.StatusInternalServerError) - log.Printf("Error refreshing data: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to refresh data", err) return } - // Find the specific board var targetBoard *models.Board for i := range data.Boards { if data.Boards[i].ID == boardID { @@ -617,47 +486,31 @@ func (h *Handler) HandleCreateCard(w http.ResponseWriter, r *http.Request) { } if targetBoard == nil { - http.Error(w, "Board not found", http.StatusNotFound) + JSONError(w, http.StatusNotFound, "Board not found", nil) return } - // Render the updated board - if err := h.templates.ExecuteTemplate(w, "trello-board", targetBoard); err != nil { - http.Error(w, "Failed to render template", http.StatusInternalServerError) - log.Printf("Error rendering board template: %v", err) - } + HTMLResponse(w, h.templates, "trello-board", targetBoard) } // HandleCompleteCard marks a Trello card as complete func (h *Handler) HandleCompleteCard(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Parse form data if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) - log.Printf("Error parsing form: %v", err) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } cardID := r.FormValue("card_id") - if cardID == "" { - http.Error(w, "Missing card_id", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing card_id", nil) return } - // Mark card as closed (complete) - updates := map[string]interface{}{ - "closed": true, - } - - if err := h.trelloClient.UpdateCard(ctx, cardID, updates); err != nil { - http.Error(w, "Failed to complete card", http.StatusInternalServerError) - log.Printf("Error completing card: %v", err) + if err := h.trelloClient.UpdateCard(r.Context(), cardID, map[string]interface{}{"closed": true}); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to complete card", err) return } - // Return empty response (card will be removed from DOM) w.WriteHeader(http.StatusOK) } @@ -665,10 +518,8 @@ func (h *Handler) HandleCompleteCard(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandleCreateTask(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Parse form data if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) - log.Printf("Error parsing form: %v", err) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } @@ -676,85 +527,68 @@ func (h *Handler) HandleCreateTask(w http.ResponseWriter, r *http.Request) { projectID := r.FormValue("project_id") if content == "" { - http.Error(w, "Missing content", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing content", nil) return } - // Create the task - _, err := h.todoistClient.CreateTask(ctx, content, projectID, nil, 0) - if err != nil { - http.Error(w, "Failed to create task", http.StatusInternalServerError) - log.Printf("Error creating task: %v", err) + if _, err := h.todoistClient.CreateTask(ctx, content, projectID, nil, 0); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to create task", err) return } - // Force refresh to get updated tasks tasks, err := h.fetchTasks(ctx, true) if err != nil { - http.Error(w, "Failed to refresh tasks", http.StatusInternalServerError) - log.Printf("Error refreshing tasks: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to refresh tasks", err) return } - // Fetch projects for the dropdown - projects, err := h.todoistClient.GetProjects(ctx) - if err != nil { - log.Printf("Failed to fetch projects: %v", err) - projects = []models.Project{} - } + projects, _ := h.todoistClient.GetProjects(ctx) - // Prepare data for template rendering data := struct { Tasks []models.Task Projects []models.Project - }{ - Tasks: tasks, - Projects: projects, - } + }{Tasks: tasks, Projects: projects} - // Render the updated task list - if err := h.templates.ExecuteTemplate(w, "todoist-tasks", data); err != nil { - http.Error(w, "Failed to render template", http.StatusInternalServerError) - log.Printf("Error rendering todoist tasks template: %v", err) - } + HTMLResponse(w, h.templates, "todoist-tasks", data) } // HandleCompleteTask marks a Todoist task as complete func (h *Handler) HandleCompleteTask(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Parse form data if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) - log.Printf("Error parsing form: %v", err) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } taskID := r.FormValue("task_id") - if taskID == "" { - http.Error(w, "Missing task_id", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing task_id", nil) return } - // Mark task as complete - if err := h.todoistClient.CompleteTask(ctx, taskID); err != nil { - http.Error(w, "Failed to complete task", http.StatusInternalServerError) - log.Printf("Error completing task: %v", err) + if err := h.todoistClient.CompleteTask(r.Context(), taskID); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to complete task", err) return } - // Return empty response (task will be removed from DOM) w.WriteHeader(http.StatusOK) } // HandleCompleteAtom handles completion of a unified task (Atom) func (h *Handler) HandleCompleteAtom(w http.ResponseWriter, r *http.Request) { + h.handleAtomToggle(w, r, true) +} + +// HandleUncompleteAtom handles reopening a completed task +func (h *Handler) HandleUncompleteAtom(w http.ResponseWriter, r *http.Request) { + h.handleAtomToggle(w, r, false) +} + +// handleAtomToggle handles both complete and uncomplete operations +func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, complete bool) { ctx := r.Context() if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) - log.Printf("Error parsing form: %v", err) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } @@ -762,74 +596,48 @@ func (h *Handler) HandleCompleteAtom(w http.ResponseWriter, r *http.Request) { source := r.FormValue("source") if id == "" || source == "" { - http.Error(w, "Missing id or source", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing id or source", nil) return } var err error switch source { case "todoist": - err = h.todoistClient.CompleteTask(ctx, id) - case "trello": - // Archive the card (closed = true) - updates := map[string]interface{}{ - "closed": true, + if complete { + err = h.todoistClient.CompleteTask(ctx, id) + } else { + err = h.todoistClient.ReopenTask(ctx, id) } - err = h.trelloClient.UpdateCard(ctx, id, updates) + case "trello": + err = h.trelloClient.UpdateCard(ctx, id, map[string]interface{}{"closed": complete}) default: - http.Error(w, "Unknown source: "+source, http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Unknown source: "+source, nil) return } if err != nil { - http.Error(w, "Failed to complete task", http.StatusInternalServerError) - log.Printf("Error completing atom (source=%s, id=%s): %v", source, id, err) + action := "complete" + if !complete { + action = "reopen" + } + JSONError(w, http.StatusInternalServerError, "Failed to "+action+" task", err) return } - // Get task title before removing from cache - var title string - switch source { - case "todoist": - if tasks, err := h.store.GetTasks(); err == nil { - for _, t := range tasks { - if t.ID == id { - title = t.Content - break - } - } - } - case "trello": - if boards, err := h.store.GetBoards(); err == nil { - for _, b := range boards { - for _, c := range b.Cards { - if c.ID == id { - title = c.Name - break - } - } - } - } - } - if title == "" { - title = "Task" - } + if complete { + // Get task title before removing from cache + title := h.getAtomTitle(id, source) - // Remove from local cache - switch source { - case "todoist": - if err := h.store.DeleteTask(id); err != nil { - log.Printf("Warning: failed to delete task from cache: %v", err) + // Remove from local cache + switch source { + case "todoist": + h.store.DeleteTask(id) + case "trello": + h.store.DeleteCard(id) } - case "trello": - if err := h.store.DeleteCard(id); err != nil { - log.Printf("Warning: failed to delete card from cache: %v", err) - } - } - // Return completed task HTML with uncomplete option - w.Header().Set("Content-Type", "text/html") - completedHTML := fmt.Sprintf(`
+ // Return completed task HTML with uncomplete option + completedHTML := fmt.Sprintf(`
`, template.HTMLEscapeString(id), template.HTMLEscapeString(source), template.HTMLEscapeString(title)) - w.Write([]byte(completedHTML)) -} - -// HandleUncompleteAtom handles reopening a completed task -func (h *Handler) HandleUncompleteAtom(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) - log.Printf("Error parsing form: %v", err) - return - } - - id := r.FormValue("id") - source := r.FormValue("source") - - if id == "" || source == "" { - http.Error(w, "Missing id or source", http.StatusBadRequest) - return - } - - var err error - switch source { - case "todoist": - err = h.todoistClient.ReopenTask(ctx, id) - case "trello": - // Reopen the card (closed = false) - updates := map[string]interface{}{ - "closed": false, + HTMLString(w, completedHTML) + } else { + // Invalidate cache to force refresh + switch source { + case "todoist": + h.store.InvalidateCache(store.CacheKeyTodoistTasks) + case "trello": + h.store.InvalidateCache(store.CacheKeyTrelloBoards) } - err = h.trelloClient.UpdateCard(ctx, id, updates) - default: - http.Error(w, "Unknown source: "+source, http.StatusBadRequest) - return - } - - if err != nil { - http.Error(w, "Failed to reopen task", http.StatusInternalServerError) - log.Printf("Error reopening atom (source=%s, id=%s): %v", source, id, err) - return + w.Header().Set("HX-Trigger", "refresh-tasks") + w.WriteHeader(http.StatusOK) } +} - // Invalidate cache to force refresh +// getAtomTitle retrieves the title for a task/card from the store +func (h *Handler) getAtomTitle(id, source string) string { switch source { case "todoist": - h.store.InvalidateCache(store.CacheKeyTodoistTasks) + if tasks, err := h.store.GetTasks(); err == nil { + for _, t := range tasks { + if t.ID == id { + return t.Content + } + } + } case "trello": - h.store.InvalidateCache(store.CacheKeyTrelloBoards) + if boards, err := h.store.GetBoards(); err == nil { + for _, b := range boards { + for _, c := range b.Cards { + if c.ID == id { + return c.Name + } + } + } + } } - - // Trigger refresh - w.Header().Set("HX-Trigger", "refresh-tasks") - w.WriteHeader(http.StatusOK) + return "Task" } // HandleUnifiedAdd creates a task in Todoist or a card in Trello from the Quick Add form @@ -904,7 +696,7 @@ func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } @@ -913,69 +705,57 @@ func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) { dueDateStr := r.FormValue("due_date") if title == "" { - http.Error(w, "Title is required", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Title is required", nil) return } - // Parse due date if provided (use local timezone) var dueDate *time.Time if dueDateStr != "" { - parsed, err := time.ParseInLocation("2006-01-02", dueDateStr, time.Local) - if err == nil { + if parsed, err := time.ParseInLocation("2006-01-02", dueDateStr, time.Local); err == nil { dueDate = &parsed } } switch source { case "todoist": - _, err := h.todoistClient.CreateTask(ctx, title, "", dueDate, 1) - if err != nil { - http.Error(w, "Failed to create Todoist task", http.StatusInternalServerError) - log.Printf("Error creating Todoist task: %v", err) + if _, err := h.todoistClient.CreateTask(ctx, title, "", dueDate, 1); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to create Todoist task", err) return } - // Invalidate cache so fresh data is fetched h.store.InvalidateCache(store.CacheKeyTodoistTasks) case "trello": listID := r.FormValue("list_id") if listID == "" { - http.Error(w, "List is required for Trello", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "List is required for Trello", nil) return } - _, err := h.trelloClient.CreateCard(ctx, listID, title, "", dueDate) - if err != nil { - http.Error(w, "Failed to create Trello card", http.StatusInternalServerError) - log.Printf("Error creating Trello card: %v", err) + if _, err := h.trelloClient.CreateCard(ctx, listID, title, "", dueDate); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to create Trello card", err) return } - // Invalidate cache so fresh data is fetched h.store.InvalidateCache(store.CacheKeyTrelloBoards) default: - http.Error(w, "Invalid source", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Invalid source", nil) return } - // Trigger a refresh of the tasks tab via HTMX w.Header().Set("HX-Trigger", "refresh-tasks") w.WriteHeader(http.StatusOK) } // HandleGetListsOptions returns HTML options for lists in a given board func (h *Handler) HandleGetListsOptions(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() boardID := r.URL.Query().Get("board_id") - if boardID == "" { - http.Error(w, "board_id is required", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "board_id is required", nil) return } - lists, err := h.trelloClient.GetLists(ctx, boardID) + lists, err := h.trelloClient.GetLists(r.Context(), boardID) if err != nil { - http.Error(w, "Failed to fetch lists", http.StatusInternalServerError) - log.Printf("Error fetching lists for board %s: %v", boardID, err) + JSONError(w, http.StatusInternalServerError, "Failed to fetch lists", err) return } @@ -989,8 +769,7 @@ func (h *Handler) HandleGetListsOptions(w http.ResponseWriter, r *http.Request) func (h *Handler) HandleGetBugs(w http.ResponseWriter, r *http.Request) { bugs, err := h.store.GetBugs() if err != nil { - http.Error(w, "Failed to fetch bugs", http.StatusInternalServerError) - log.Printf("Error fetching bugs: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to fetch bugs", err) return } @@ -1011,23 +790,21 @@ func (h *Handler) HandleGetBugs(w http.ResponseWriter, r *http.Request) { // HandleReportBug saves a new bug report func (h *Handler) HandleReportBug(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - http.Error(w, "Invalid form data", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Invalid form data", err) return } description := strings.TrimSpace(r.FormValue("description")) if description == "" { - http.Error(w, "Description is required", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Description is required", nil) return } if err := h.store.SaveBug(description); err != nil { - http.Error(w, "Failed to save bug", http.StatusInternalServerError) - log.Printf("Error saving bug: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to save bug", err) return } - // Return updated bug list h.HandleGetBugs(w, r) } @@ -1037,32 +814,27 @@ func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) { source := r.URL.Query().Get("source") if id == "" || source == "" { - http.Error(w, "Missing id or source", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing id or source", nil) return } var title, description string switch source { case "todoist": - tasks, err := h.store.GetTasks() - if err == nil { + if tasks, err := h.store.GetTasks(); err == nil { for _, t := range tasks { if t.ID == id { - title = t.Content - description = t.Description + title, description = t.Content, t.Description break } } } case "trello": - boards, err := h.store.GetBoards() - if err == nil { + if boards, err := h.store.GetBoards(); err == nil { for _, b := range boards { for _, c := range b.Cards { if c.ID == id { title = c.Name - // Card model doesn't store description, leave empty - description = "" break } } @@ -1070,7 +842,6 @@ func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) { } } - w.Header().Set("Content-Type", "text/html") html := fmt.Sprintf(`

%s

@@ -1083,18 +854,17 @@ func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) {
`, template.HTMLEscapeString(title), template.HTMLEscapeString(id), template.HTMLEscapeString(source), template.HTMLEscapeString(description)) - w.Write([]byte(html)) + HTMLString(w, html) } // HandleGetShoppingLists returns the lists from the Shopping board for quick-add func (h *Handler) HandleGetShoppingLists(w http.ResponseWriter, r *http.Request) { boards, err := h.store.GetBoards() if err != nil { - http.Error(w, "Failed to get boards", http.StatusInternalServerError) + JSONError(w, http.StatusInternalServerError, "Failed to get boards", err) return } - // Find the Shopping board var shoppingBoardID string for _, b := range boards { if strings.EqualFold(b.Name, "Shopping") { @@ -1104,15 +874,13 @@ func (h *Handler) HandleGetShoppingLists(w http.ResponseWriter, r *http.Request) } if shoppingBoardID == "" { - http.Error(w, "Shopping board not found", http.StatusNotFound) + JSONError(w, http.StatusNotFound, "Shopping board not found", nil) return } - // Get lists for the shopping board lists, err := h.trelloClient.GetLists(r.Context(), shoppingBoardID) if err != nil { - http.Error(w, "Failed to get lists", http.StatusInternalServerError) - log.Printf("Error fetching shopping lists: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to get lists", err) return } @@ -1124,10 +892,8 @@ func (h *Handler) HandleGetShoppingLists(w http.ResponseWriter, r *http.Request) // HandleUpdateTask updates a task description func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } @@ -1136,28 +902,291 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { description := r.FormValue("description") if id == "" || source == "" { - http.Error(w, "Missing id or source", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing id or source", nil) return } var err error switch source { case "todoist": - updates := map[string]interface{}{"description": description} - err = h.todoistClient.UpdateTask(ctx, id, updates) + err = h.todoistClient.UpdateTask(r.Context(), id, map[string]interface{}{"description": description}) case "trello": - updates := map[string]interface{}{"desc": description} - err = h.trelloClient.UpdateCard(ctx, id, updates) + err = h.trelloClient.UpdateCard(r.Context(), id, map[string]interface{}{"desc": description}) default: - http.Error(w, "Unknown source", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Unknown source", nil) return } if err != nil { - http.Error(w, "Failed to update task", http.StatusInternalServerError) - log.Printf("Error updating task: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to update task", err) return } w.WriteHeader(http.StatusOK) } + +// HandleTabTasks renders the unified Tasks tab (Todoist + Trello cards with due dates) +func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { + tasks, err := h.store.GetTasks() + if err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to fetch tasks", err) + return + } + + boards, err := h.store.GetBoards() + if err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to fetch boards", err) + return + } + + atoms := make([]models.Atom, 0) + + for _, task := range tasks { + if !task.Completed { + atoms = append(atoms, models.TaskToAtom(task)) + } + } + + for _, board := range boards { + for _, card := range board.Cards { + if card.DueDate != nil || isActionableList(card.ListName) { + atoms = append(atoms, models.CardToAtom(card)) + } + } + } + + for i := range atoms { + atoms[i].ComputeUIFields() + } + + sort.SliceStable(atoms, func(i, j int) bool { + tierI := atomUrgencyTier(atoms[i]) + tierJ := atomUrgencyTier(atoms[j]) + + if tierI != tierJ { + return tierI < tierJ + } + + if atoms[i].DueDate != nil && atoms[j].DueDate != nil { + if !atoms[i].DueDate.Equal(*atoms[j].DueDate) { + return atoms[i].DueDate.Before(*atoms[j].DueDate) + } + } + + return atoms[i].Priority > atoms[j].Priority + }) + + var currentAtoms, futureAtoms []models.Atom + for _, a := range atoms { + if a.IsFuture { + futureAtoms = append(futureAtoms, a) + } else { + currentAtoms = append(currentAtoms, a) + } + } + + data := struct { + Atoms []models.Atom + FutureAtoms []models.Atom + Boards []models.Board + Today string + }{ + Atoms: currentAtoms, + FutureAtoms: futureAtoms, + Boards: boards, + Today: time.Now().Format("2006-01-02"), + } + + HTMLResponse(w, h.templates, "tasks-tab", data) +} + +// HandleTabPlanning renders the Planning tab with structured sections +func (h *Handler) HandleTabPlanning(w http.ResponseWriter, r *http.Request) { + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + tomorrow := today.AddDate(0, 0, 1) + in3Days := today.AddDate(0, 0, 4) + + boards, _ := h.store.GetBoards() + tasks, _ := h.store.GetTasks() + + var events []models.CalendarEvent + if h.googleCalendarClient != nil { + events, _ = h.googleCalendarClient.GetUpcomingEvents(r.Context(), 20) + } + + var scheduled []ScheduledItem + var unscheduled []models.Atom + var upcoming []ScheduledItem + + for _, event := range events { + item := ScheduledItem{ + Type: "event", + ID: event.ID, + Title: event.Summary, + Start: event.Start, + End: event.End, + URL: event.HTMLLink, + Source: "calendar", + SourceIcon: "📅", + } + + if event.Start.Before(tomorrow) { + scheduled = append(scheduled, item) + } else if event.Start.Before(in3Days) { + upcoming = append(upcoming, item) + } + } + + for _, task := range tasks { + if task.Completed || task.DueDate == nil { + continue + } + dueDate := *task.DueDate + hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0 + + if dueDate.Before(tomorrow) { + if hasTime { + scheduled = append(scheduled, ScheduledItem{ + Type: "task", + ID: task.ID, + Title: task.Content, + Start: dueDate, + URL: task.URL, + Source: "todoist", + SourceIcon: "✓", + Priority: task.Priority, + }) + } else { + atom := models.TaskToAtom(task) + atom.ComputeUIFields() + unscheduled = append(unscheduled, atom) + } + } else if dueDate.Before(in3Days) { + upcoming = append(upcoming, ScheduledItem{ + Type: "task", + ID: task.ID, + Title: task.Content, + Start: dueDate, + URL: task.URL, + Source: "todoist", + SourceIcon: "✓", + Priority: task.Priority, + }) + } + } + + for _, board := range boards { + for _, card := range board.Cards { + if card.DueDate == nil { + continue + } + dueDate := *card.DueDate + hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0 + + if dueDate.Before(tomorrow) { + if hasTime { + scheduled = append(scheduled, ScheduledItem{ + Type: "task", + ID: card.ID, + Title: card.Name, + Start: dueDate, + URL: card.URL, + Source: "trello", + SourceIcon: "📋", + }) + } else { + atom := models.CardToAtom(card) + atom.ComputeUIFields() + unscheduled = append(unscheduled, atom) + } + } else if dueDate.Before(in3Days) { + upcoming = append(upcoming, ScheduledItem{ + Type: "task", + ID: card.ID, + Title: card.Name, + Start: dueDate, + URL: card.URL, + Source: "trello", + SourceIcon: "📋", + }) + } + } + } + + sort.Slice(scheduled, func(i, j int) bool { return scheduled[i].Start.Before(scheduled[j].Start) }) + sort.Slice(unscheduled, func(i, j int) bool { return unscheduled[i].Priority > unscheduled[j].Priority }) + sort.Slice(upcoming, func(i, j int) bool { return upcoming[i].Start.Before(upcoming[j].Start) }) + + data := struct { + Scheduled []ScheduledItem + Unscheduled []models.Atom + Upcoming []ScheduledItem + Boards []models.Board + Today string + }{ + Scheduled: scheduled, + Unscheduled: unscheduled, + Upcoming: upcoming, + Boards: boards, + Today: today.Format("2006-01-02"), + } + + HTMLResponse(w, h.templates, "planning-tab", data) +} + +// HandleTabMeals renders the Meals tab (PlanToEat) +func (h *Handler) HandleTabMeals(w http.ResponseWriter, r *http.Request) { + startDate := time.Now() + endDate := startDate.AddDate(0, 0, 7) + + meals, err := h.store.GetMeals(startDate, endDate) + if err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to fetch meals", err) + return + } + + HTMLResponse(w, h.templates, "meals-tab", struct{ Meals []models.Meal }{meals}) +} + +// isActionableList returns true if the list name indicates an actionable list +func isActionableList(name string) bool { + lower := strings.ToLower(name) + return strings.Contains(lower, "doing") || + strings.Contains(lower, "in progress") || + strings.Contains(lower, "to do") || + strings.Contains(lower, "todo") || + strings.Contains(lower, "tasks") || + strings.Contains(lower, "next") || + strings.Contains(lower, "today") +} + +// atomUrgencyTier returns the urgency tier for sorting +func atomUrgencyTier(a models.Atom) int { + if a.DueDate == nil { + return 4 + } + if a.IsOverdue { + return 0 + } + if a.IsFuture { + return 3 + } + if a.HasSetTime { + return 1 + } + return 2 +} + +// ScheduledItem represents a scheduled event or task for the planning view +type ScheduledItem struct { + Type string + ID string + Title string + Start time.Time + End time.Time + URL string + Source string + SourceIcon string + Priority int +} -- cgit v1.2.3