summaryrefslogtreecommitdiff
path: root/internal/handlers/handlers.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-23 21:37:18 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-23 21:37:18 -1000
commit465093343ddd398ce5f6377fc9c472d8251c618b (patch)
treed333a2f1c8879f7b114817e929c95e9fcf5f4c3b /internal/handlers/handlers.go
parente23c85577cbb0eac8b847dd989072698ff4e7a30 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'internal/handlers/handlers.go')
-rw-r--r--internal/handlers/handlers.go1029
1 files changed, 529 insertions, 500 deletions
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(`<div class="bg-white/5 rounded-lg border border-white/5 opacity-60">
+ // Return completed task HTML with uncomplete option
+ completedHTML := fmt.Sprintf(`<div class="bg-white/5 rounded-lg border border-white/5 opacity-60">
<div class="flex items-start gap-2 sm:gap-3 p-3 sm:p-4">
<input type="checkbox" checked
hx-post="/uncomplete-atom"
@@ -844,59 +652,43 @@ func (h *Handler) HandleCompleteAtom(w http.ResponseWriter, r *http.Request) {
</div>
</div>
</div>`, 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(`
<div class="p-4">
<h3 class="font-semibold text-gray-900 mb-3">%s</h3>
@@ -1083,18 +854,17 @@ func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) {
</form>
</div>
`, 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
+}