summaryrefslogtreecommitdiff
path: root/internal/handlers
diff options
context:
space:
mode:
Diffstat (limited to 'internal/handlers')
-rw-r--r--internal/handlers/cache.go52
-rw-r--r--internal/handlers/handlers.go1029
-rw-r--r--internal/handlers/heuristic_test.go152
-rw-r--r--internal/handlers/response.go36
-rw-r--r--internal/handlers/tabs.go384
5 files changed, 699 insertions, 954 deletions
diff --git a/internal/handlers/cache.go b/internal/handlers/cache.go
new file mode 100644
index 0000000..a2f534e
--- /dev/null
+++ b/internal/handlers/cache.go
@@ -0,0 +1,52 @@
+package handlers
+
+import (
+ "context"
+ "log"
+
+ "task-dashboard/internal/store"
+)
+
+// CacheFetcher defines the interface for fetching and caching data
+type CacheFetcher[T any] struct {
+ Store *store.Store
+ CacheKey string
+ TTLMinutes int
+ Fetch func(ctx context.Context) ([]T, error)
+ GetFromCache func() ([]T, error)
+ SaveToCache func([]T) error
+}
+
+// FetchWithCache fetches data from cache or API with fallback
+func (cf *CacheFetcher[T]) FetchWithCache(ctx context.Context, forceRefresh bool) ([]T, error) {
+ // Check cache validity
+ if !forceRefresh {
+ valid, err := cf.Store.IsCacheValid(cf.CacheKey)
+ if err == nil && valid {
+ return cf.GetFromCache()
+ }
+ }
+
+ // Fetch from API
+ data, err := cf.Fetch(ctx)
+ if err != nil {
+ // Try to return cached data even if stale
+ cachedData, cacheErr := cf.GetFromCache()
+ if cacheErr == nil && len(cachedData) > 0 {
+ return cachedData, nil
+ }
+ return nil, err
+ }
+
+ // Save to cache
+ if err := cf.SaveToCache(data); err != nil {
+ log.Printf("Failed to save to cache (%s): %v", cf.CacheKey, err)
+ }
+
+ // Update cache metadata
+ if err := cf.Store.UpdateCacheMetadata(cf.CacheKey, cf.TTLMinutes); err != nil {
+ log.Printf("Failed to update cache metadata (%s): %v", cf.CacheKey, err)
+ }
+
+ return data, nil
+}
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
+}
diff --git a/internal/handlers/heuristic_test.go b/internal/handlers/heuristic_test.go
index b03b664..82f4e90 100644
--- a/internal/handlers/heuristic_test.go
+++ b/internal/handlers/heuristic_test.go
@@ -1,97 +1,109 @@
package handlers
import (
- "net/http/httptest"
- "os"
- "strings"
"testing"
"time"
"task-dashboard/internal/models"
- "task-dashboard/internal/store"
)
-func TestHandleTasks_Heuristic(t *testing.T) {
- // Create temp database file
- tmpFile, err := os.CreateTemp("", "test_heuristic_*.db")
- if err != nil {
- t.Fatalf("Failed to create temp db: %v", err)
+func TestIsActionableList(t *testing.T) {
+ tests := []struct {
+ name string
+ listName string
+ want bool
+ }{
+ {"doing list", "Doing", true},
+ {"in progress", "In Progress", true},
+ {"to do", "To Do", true},
+ {"todo", "todo", true},
+ {"tasks", "My Tasks", true},
+ {"next", "Next Up", true},
+ {"today", "Today", true},
+ {"backlog", "Backlog", false},
+ {"done", "Done", false},
+ {"ideas", "Ideas", false},
}
- tmpFile.Close()
- defer os.Remove(tmpFile.Name())
- // Save current directory and change to project root
- originalDir, err := os.Getwd()
- if err != nil {
- t.Fatalf("Failed to get working directory: %v", err)
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isActionableList(tt.listName); got != tt.want {
+ t.Errorf("isActionableList(%q) = %v, want %v", tt.listName, got, tt.want)
+ }
+ })
}
+}
- // Change to project root (2 levels up from internal/handlers)
- if err := os.Chdir("../../"); err != nil {
- t.Fatalf("Failed to change to project root: %v", err)
- }
- defer os.Chdir(originalDir)
-
- // Initialize store (this runs migrations)
- db, err := store.New(tmpFile.Name(), "migrations")
- if err != nil {
- t.Fatalf("Failed to initialize store: %v", err)
- }
- defer db.Close()
-
- // Seed Data
- // Board 1: Has actionable lists
- board1 := models.Board{ID: "b1", Name: "Work Board"}
-
- // Card 1: Has Due Date (Should appear)
+func TestFilterAndSortTrelloTasks(t *testing.T) {
due := time.Now()
- card1 := models.Card{ID: "c1", Name: "Due Task", ListID: "l1", ListName: "Backlog", DueDate: &due, BoardName: "Work Board"}
-
- // Card 2: No Due Date, Actionable List (Should appear)
- card2 := models.Card{ID: "c2", Name: "Doing Task", ListID: "l2", ListName: "Doing", BoardName: "Work Board"}
-
- // Card 3: No Due Date, Non-Actionable List (Should NOT appear)
- card3 := models.Card{ID: "c3", Name: "Backlog Task", ListID: "l1", ListName: "Backlog", BoardName: "Work Board"}
-
- // Card 4: No Due Date, "To Do" List (Should appear)
- card4 := models.Card{ID: "c4", Name: "Todo Task", ListID: "l3", ListName: "To Do", BoardName: "Work Board"}
-
- board1.Cards = []models.Card{card1, card2, card3, card4}
-
- if err := db.SaveBoards([]models.Board{board1}); err != nil {
- t.Fatalf("Failed to save boards: %v", err)
+ boards := []models.Board{
+ {
+ ID: "b1",
+ Name: "Work Board",
+ Cards: []models.Card{
+ {ID: "c1", Name: "Due Task", ListName: "Backlog", DueDate: &due, BoardName: "Work Board"},
+ {ID: "c2", Name: "Doing Task", ListName: "Doing", BoardName: "Work Board"},
+ {ID: "c3", Name: "Backlog Task", ListName: "Backlog", BoardName: "Work Board"},
+ {ID: "c4", Name: "Todo Task", ListName: "To Do", BoardName: "Work Board"},
+ },
+ },
}
- // Create Handler
- h := NewTabsHandler(db, nil, "../../web/templates")
+ tasks := filterAndSortTrelloTasks(boards)
- // Skip if templates are not loaded
- if h.templates == nil {
- t.Skip("Templates not available in test environment")
+ // Should have 3 tasks: c1 (has due date), c2 (Doing list), c4 (To Do list)
+ if len(tasks) != 3 {
+ t.Errorf("Expected 3 tasks, got %d", len(tasks))
}
- req := httptest.NewRequest("GET", "/tabs/tasks", nil)
- w := httptest.NewRecorder()
-
- // Execute
- h.HandleTasks(w, req)
+ // Verify c3 (Backlog without due date) is not included
+ for _, task := range tasks {
+ if task.ID == "c3" {
+ t.Error("Backlog task without due date should not be included")
+ }
+ }
- // Verify
- resp := w.Body.String()
+ // Verify expected tasks are present
+ found := map[string]bool{}
+ for _, task := range tasks {
+ found[task.ID] = true
+ }
- // Check for presence of expected tasks
- if !strings.Contains(resp, "Due Task") {
- t.Errorf("Expected 'Due Task' to be present")
+ if !found["c1"] {
+ t.Error("Expected c1 (Due Task) to be present")
+ }
+ if !found["c2"] {
+ t.Error("Expected c2 (Doing Task) to be present")
}
- if !strings.Contains(resp, "Doing Task") {
- t.Errorf("Expected 'Doing Task' to be present")
+ if !found["c4"] {
+ t.Error("Expected c4 (Todo Task) to be present")
}
- if !strings.Contains(resp, "Todo Task") {
- t.Errorf("Expected 'Todo Task' to be present")
+}
+
+func TestAtomUrgencyTier(t *testing.T) {
+ now := time.Now()
+ yesterday := now.AddDate(0, 0, -1)
+ tomorrow := now.AddDate(0, 0, 1)
+ todayWithTime := time.Date(now.Year(), now.Month(), now.Day(), 14, 30, 0, 0, now.Location())
+ todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+
+ tests := []struct {
+ name string
+ atom models.Atom
+ want int
+ }{
+ {"no due date", models.Atom{DueDate: nil}, 4},
+ {"overdue", models.Atom{DueDate: &yesterday, IsOverdue: true}, 0},
+ {"future", models.Atom{DueDate: &tomorrow, IsFuture: true}, 3},
+ {"today with time", models.Atom{DueDate: &todayWithTime, HasSetTime: true}, 1},
+ {"today all-day", models.Atom{DueDate: &todayMidnight, HasSetTime: false}, 2},
}
- // Check for absence of non-expected tasks
- if strings.Contains(resp, "Backlog Task") {
- t.Errorf("Expected 'Backlog Task' to be ABSENT")
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := atomUrgencyTier(tt.atom); got != tt.want {
+ t.Errorf("atomUrgencyTier() = %v, want %v", got, tt.want)
+ }
+ })
}
}
diff --git a/internal/handlers/response.go b/internal/handlers/response.go
new file mode 100644
index 0000000..3976f02
--- /dev/null
+++ b/internal/handlers/response.go
@@ -0,0 +1,36 @@
+package handlers
+
+import (
+ "encoding/json"
+ "html/template"
+ "log"
+ "net/http"
+)
+
+// JSONResponse writes data as JSON with appropriate headers
+func JSONResponse(w http.ResponseWriter, data interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(data)
+}
+
+// JSONError writes an error response as JSON
+func JSONError(w http.ResponseWriter, status int, msg string, err error) {
+ if err != nil {
+ log.Printf("Error: %s: %v", msg, err)
+ }
+ http.Error(w, msg, status)
+}
+
+// HTMLResponse renders an HTML template
+func HTMLResponse(w http.ResponseWriter, tmpl *template.Template, name string, data interface{}) {
+ if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
+ http.Error(w, "Failed to render template", http.StatusInternalServerError)
+ log.Printf("Error rendering template %s: %v", name, err)
+ }
+}
+
+// HTMLString writes an HTML string directly
+func HTMLString(w http.ResponseWriter, html string) {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(html))
+}
diff --git a/internal/handlers/tabs.go b/internal/handlers/tabs.go
deleted file mode 100644
index 87be344..0000000
--- a/internal/handlers/tabs.go
+++ /dev/null
@@ -1,384 +0,0 @@
-package handlers
-
-import (
- "html/template"
- "log"
- "net/http"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- "task-dashboard/internal/api"
- "task-dashboard/internal/models"
- "task-dashboard/internal/store"
-)
-
-// 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:
-// 0: Overdue, 1: Today with time, 2: Today all-day, 3: Future, 4: No due date
-func atomUrgencyTier(a models.Atom) int {
- if a.DueDate == nil {
- return 4 // No due date
- }
- if a.IsOverdue {
- return 0 // Overdue
- }
- if a.IsFuture {
- return 3 // Future
- }
- // Due today
- if a.HasSetTime {
- return 1 // Today with specific time
- }
- return 2 // Today all-day
-}
-
-// TabsHandler handles tab-specific rendering with Atom model
-type TabsHandler struct {
- store *store.Store
- googleCalendarClient api.GoogleCalendarAPI
- templates *template.Template
-}
-
-// NewTabsHandler creates a new TabsHandler instance
-func NewTabsHandler(store *store.Store, googleCalendarClient api.GoogleCalendarAPI, templateDir string) *TabsHandler {
- // Parse templates including partials
- tmpl, err := template.ParseGlob(filepath.Join(templateDir, "*.html"))
- if err != nil {
- log.Printf("Warning: failed to parse templates: %v", err)
- }
-
- // Also parse partials
- tmpl, err = tmpl.ParseGlob(filepath.Join(templateDir, "partials", "*.html"))
- if err != nil {
- log.Printf("Warning: failed to parse partial templates: %v", err)
- }
-
- return &TabsHandler{
- store: store,
- googleCalendarClient: googleCalendarClient,
- templates: tmpl,
- }
-}
-
-// HandleTasks renders the unified Tasks tab (Todoist + Trello cards with due dates)
-func (h *TabsHandler) HandleTasks(w http.ResponseWriter, r *http.Request) {
- // Fetch Todoist tasks
- tasks, err := h.store.GetTasks()
- if err != nil {
- http.Error(w, "Failed to fetch tasks", http.StatusInternalServerError)
- log.Printf("Error fetching tasks: %v", err)
- return
- }
-
- // Fetch Trello boards
- boards, err := h.store.GetBoards()
- if err != nil {
- http.Error(w, "Failed to fetch boards", http.StatusInternalServerError)
- log.Printf("Error fetching boards: %v", err)
- return
- }
-
- // Convert to Atoms
- atoms := make([]models.Atom, 0)
-
- // Convert Todoist tasks
- for _, task := range tasks {
- if !task.Completed {
- atoms = append(atoms, models.TaskToAtom(task))
- }
- }
-
- // Convert Trello cards with due dates or in actionable lists
- for _, board := range boards {
- for _, card := range board.Cards {
- if card.DueDate != nil || isActionableList(card.ListName) {
- atoms = append(atoms, models.CardToAtom(card))
- }
- }
- }
-
- // Compute UI fields (IsOverdue, IsFuture, HasSetTime)
- for i := range atoms {
- atoms[i].ComputeUIFields()
- }
-
- // Sort atoms by urgency tiers:
- // 1. Overdue (before today)
- // 2. Today with specific time
- // 3. Today all-day (midnight)
- // 4. Future
- // 5. No due date
- // Within each tier: sort by due date/time, then by priority
- sort.SliceStable(atoms, func(i, j int) bool {
- // Compute urgency tier (lower = more urgent)
- tierI := atomUrgencyTier(atoms[i])
- tierJ := atomUrgencyTier(atoms[j])
-
- if tierI != tierJ {
- return tierI < tierJ
- }
-
- // Same tier: sort by due date/time if both have dates
- 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)
- }
- }
-
- // Same due date/time (or both nil), sort by priority (descending)
- return atoms[i].Priority > atoms[j].Priority
- })
-
- // Partition atoms into current (overdue + today) and future
- var currentAtoms, futureAtoms []models.Atom
- for _, a := range atoms {
- if a.IsFuture {
- futureAtoms = append(futureAtoms, a)
- } else {
- currentAtoms = append(currentAtoms, a)
- }
- }
-
- // Render template
- data := struct {
- Atoms []models.Atom // Current tasks (overdue + today)
- FutureAtoms []models.Atom // Future tasks (hidden by default)
- Boards []models.Board
- Today string
- }{
- Atoms: currentAtoms,
- FutureAtoms: futureAtoms,
- Boards: boards,
- Today: time.Now().Format("2006-01-02"),
- }
-
- 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)
- }
-}
-
-// ScheduledItem represents a scheduled event or task for the planning view
-type ScheduledItem struct {
- Type string // "event" or "task"
- ID string
- Title string
- Description string
- Start time.Time
- End time.Time
- URL string
- Source string // "todoist", "trello", "calendar"
- SourceIcon string
- Priority int
-}
-
-// HandlePlanning renders the Planning tab with structured sections
-func (h *TabsHandler) HandlePlanning(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) // End of 3rd day
-
- // Fetch Trello boards
- boards, err := h.store.GetBoards()
- if err != nil {
- log.Printf("Error fetching boards: %v", err)
- boards = []models.Board{}
- }
-
- // Fetch Todoist tasks
- tasks, err := h.store.GetTasks()
- if err != nil {
- log.Printf("Error fetching tasks: %v", err)
- tasks = []models.Task{}
- }
-
- // Fetch Google Calendar events
- var events []models.CalendarEvent
- if h.googleCalendarClient != nil {
- events, err = h.googleCalendarClient.GetUpcomingEvents(r.Context(), 20)
- if err != nil {
- log.Printf("Error fetching calendar events: %v", err)
- }
- }
-
- // Categorize into sections
- var scheduled []ScheduledItem // Events and timed tasks for today
- var unscheduled []models.Atom // Tasks due today without specific time
- var upcoming []ScheduledItem // Events and tasks for next 3 days
-
- // Process calendar events
- for _, event := range events {
- item := ScheduledItem{
- Type: "event",
- ID: event.ID,
- Title: event.Summary,
- Description: event.Description,
- 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)
- }
- }
-
- // Process Todoist tasks
- for _, task := range tasks {
- if task.Completed || task.DueDate == nil {
- continue
- }
- dueDate := *task.DueDate
-
- // Check if task has a specific time (not midnight)
- hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0
-
- if dueDate.Before(tomorrow) {
- if hasTime {
- // Timed task for today -> scheduled
- scheduled = append(scheduled, ScheduledItem{
- Type: "task",
- ID: task.ID,
- Title: task.Content,
- Start: dueDate,
- URL: task.URL,
- Source: "todoist",
- SourceIcon: "✓",
- Priority: task.Priority,
- })
- } else {
- // All-day task for today -> unscheduled
- 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,
- })
- }
- }
-
- // Process Trello cards with due dates
- 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 scheduled by start time
- sort.Slice(scheduled, func(i, j int) bool {
- return scheduled[i].Start.Before(scheduled[j].Start)
- })
-
- // Sort unscheduled by priority (higher first)
- sort.Slice(unscheduled, func(i, j int) bool {
- return unscheduled[i].Priority > unscheduled[j].Priority
- })
-
- // Sort upcoming by date
- 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"),
- }
-
- if err := h.templates.ExecuteTemplate(w, "planning-tab", data); err != nil {
- http.Error(w, "Failed to render template", http.StatusInternalServerError)
- log.Printf("Error rendering planning tab: %v", err)
- }
-}
-
-// HandleMeals renders the Meals tab (PlanToEat)
-func (h *TabsHandler) HandleMeals(w http.ResponseWriter, r *http.Request) {
- // Fetch meals for next 7 days
- startDate := time.Now()
- endDate := startDate.AddDate(0, 0, 7)
-
- meals, err := h.store.GetMeals(startDate, endDate)
- if err != nil {
- http.Error(w, "Failed to fetch meals", http.StatusInternalServerError)
- log.Printf("Error fetching meals: %v", err)
- return
- }
-
- data := struct {
- Meals []models.Meal
- }{
- Meals: meals,
- }
-
- if err := h.templates.ExecuteTemplate(w, "meals-tab", data); err != nil {
- http.Error(w, "Failed to render template", http.StatusInternalServerError)
- log.Printf("Error rendering meals tab: %v", err)
- }
-}