package handlers import ( "context" "encoding/json" "fmt" "html/template" "log" "net/http" "path/filepath" "sort" "strings" "sync" "time" "task-dashboard/internal/api" "task-dashboard/internal/auth" "task-dashboard/internal/config" "task-dashboard/internal/models" "task-dashboard/internal/store" ) // Handler holds dependencies for HTTP handlers type Handler struct { store *store.Store todoistClient api.TodoistAPI trelloClient api.TrelloAPI planToEatClient api.PlanToEatAPI googleCalendarClient api.GoogleCalendarAPI config *config.Config templates *template.Template } // New creates a new Handler instance func New(s *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googleCalendar api.GoogleCalendarAPI, cfg *config.Config) *Handler { // Parse templates including partials tmpl, err := template.ParseGlob(filepath.Join(cfg.TemplateDir, "*.html")) if err != nil { log.Printf("Warning: failed to parse templates: %v", err) } // Also parse partials tmpl, err = tmpl.ParseGlob(filepath.Join(cfg.TemplateDir, "partials", "*.html")) if err != nil { log.Printf("Warning: failed to parse partial templates: %v", err) } return &Handler{ store: s, todoistClient: todoist, trelloClient: trello, planToEatClient: planToEat, googleCalendarClient: googleCalendar, config: cfg, templates: tmpl, } } // HandleDashboard renders the main dashboard view func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Extract tab query parameter for state persistence tab := r.URL.Query().Get("tab") if tab == "" { tab = "tasks" } // Aggregate data from all sources dashboardData, err := h.aggregateData(ctx, false) if err != nil { http.Error(w, "Failed to load dashboard data", http.StatusInternalServerError) log.Printf("Error aggregating data: %v", err) return } // Render template if h.templates == nil { http.Error(w, "Templates not loaded", http.StatusInternalServerError) return } // Generate random background URL (Lorem Picsum - CORS-friendly) // Add random seed to get different image each load backgroundURL := fmt.Sprintf("https://picsum.photos/1920/1080?random=%d", time.Now().UnixNano()) // Wrap dashboard data with active tab for template data := struct { *models.DashboardData ActiveTab string CSRFToken string BackgroundURL string }{ DashboardData: dashboardData, ActiveTab: tab, CSRFToken: auth.GetCSRFTokenFromContext(ctx), BackgroundURL: backgroundURL, } if err := h.templates.ExecuteTemplate(w, "index.html", data); err != nil { http.Error(w, "Failed to render template", http.StatusInternalServerError) log.Printf("Error rendering template: %v", err) } } // 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) if err != nil { http.Error(w, "Failed to refresh data", http.StatusInternalServerError) log.Printf("Error refreshing data: %v", err) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(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) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(tasks) } // HandleGetMeals returns meals as JSON func (h *Handler) HandleGetMeals(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 { http.Error(w, "Failed to get meals", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(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) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(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) if err != nil { http.Error(w, "Failed to load tasks", http.StatusInternalServerError) log.Printf("Error loading tasks tab: %v", 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) } } // 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) if err != nil { http.Error(w, "Failed to refresh", http.StatusInternalServerError) log.Printf("Error refreshing tab: %v", 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) } } // aggregateData fetches and caches data from all sources func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models.DashboardData, error) { data := &models.DashboardData{ LastUpdated: time.Now(), Errors: make([]string, 0), } var wg sync.WaitGroup var mu sync.Mutex // Fetch Trello boards (PRIORITY - most important) wg.Add(1) go func() { defer wg.Done() boards, err := h.fetchBoards(ctx, forceRefresh) mu.Lock() defer mu.Unlock() if err != nil { data.Errors = append(data.Errors, "Trello: "+err.Error()) } else { data.Boards = boards } }() // Fetch Todoist tasks wg.Add(1) go func() { defer wg.Done() 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) } } // Same due date (or both nil), sort by priority (descending) return tasks[i].Priority > tasks[j].Priority }) data.Tasks = tasks } }() // Fetch Todoist projects wg.Add(1) go func() { defer wg.Done() projects, err := h.todoistClient.GetProjects(ctx) mu.Lock() defer mu.Unlock() if err != nil { log.Printf("Failed to fetch projects: %v", err) } else { data.Projects = projects } }() // Fetch PlanToEat meals (if configured) if h.planToEatClient != nil { wg.Add(1) go func() { defer wg.Done() meals, err := h.fetchMeals(ctx, forceRefresh) mu.Lock() defer mu.Unlock() if err != nil { data.Errors = append(data.Errors, "PlanToEat: "+err.Error()) } else { data.Meals = meals } }() } // Fetch Google Calendar events (if configured) if h.googleCalendarClient != nil { wg.Add(1) go func() { defer wg.Done() 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 { data.Events = events } }() } wg.Wait() // Filter Trello cards into tasks based on heuristic var trelloTasks []models.Card for _, board := range data.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) } } } // 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) } // 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 { return true } if trelloTasks[i].DueDate == nil && trelloTasks[j].DueDate != nil { return false } // Both nil or same due date: sort by board name return trelloTasks[i].BoardName < trelloTasks[j].BoardName }) data.TrelloTasks = trelloTasks return data, nil } // fetchTasks fetches tasks from cache or API using incremental sync func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.Task, error) { cacheKey := store.CacheKeyTodoistTasks syncService := "todoist" // Check cache validity if !forceRefresh { valid, err := h.store.IsCacheValid(cacheKey) if err == nil && valid { return h.store.GetTasks() } } // Get stored sync token (empty string means full sync) syncToken, err := h.store.GetSyncToken(syncService) if err != nil { log.Printf("Failed to get sync token, will do full sync: %v", err) syncToken = "" } // Force full sync if requested if forceRefresh { syncToken = "" } // Fetch using Sync API syncResp, err := h.todoistClient.Sync(ctx, syncToken) if err != nil { // Try to return cached data even if stale cachedTasks, cacheErr := h.store.GetTasks() if cacheErr == nil && len(cachedTasks) > 0 { return cachedTasks, nil } return nil, err } // Build project map from sync response projectMap := api.BuildProjectMapFromSync(syncResp.Projects) // Process sync response if syncResp.FullSync { // Full sync: replace all tasks tasks := api.ConvertSyncItemsToTasks(syncResp.Items, projectMap) if err := h.store.SaveTasks(tasks); err != nil { log.Printf("Failed to save tasks to cache: %v", err) } } else { // Incremental sync: merge changes var deletedIDs []string for _, item := range syncResp.Items { if item.IsDeleted || item.IsCompleted { deletedIDs = append(deletedIDs, item.ID) } else { // Upsert active task task := h.convertSyncItemToTask(item, projectMap) if err := h.store.UpsertTask(task); err != nil { log.Printf("Failed to upsert task %s: %v", item.ID, err) } } } // Delete removed tasks if len(deletedIDs) > 0 { if err := h.store.DeleteTasksByIDs(deletedIDs); err != nil { log.Printf("Failed to delete tasks: %v", err) } } } // Store the new sync token if err := h.store.SetSyncToken(syncService, syncResp.SyncToken); err != nil { log.Printf("Failed to save sync token: %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) } return h.store.GetTasks() } // 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{ ID: item.ID, Content: item.Content, Description: item.Description, ProjectID: item.ProjectID, ProjectName: projectMap[item.ProjectID], Priority: item.Priority, Completed: false, 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) } // Update cache metadata if err := h.store.UpdateCacheMetadata(cacheKey, h.config.CacheTTLMinutes); err != nil { log.Printf("Failed to update cache metadata: %v", err) } return meals, nil } // 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) } return boards, nil } // 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) return } boardID := r.FormValue("board_id") listID := r.FormValue("list_id") name := r.FormValue("name") if boardID == "" || listID == "" || name == "" { http.Error(w, "Missing required fields", http.StatusBadRequest) 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) 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) return } // Find the specific board var targetBoard *models.Board for i := range data.Boards { if data.Boards[i].ID == boardID { targetBoard = &data.Boards[i] break } } if targetBoard == nil { http.Error(w, "Board not found", http.StatusNotFound) 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) } } // 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) return } cardID := r.FormValue("card_id") if cardID == "" { http.Error(w, "Missing card_id", http.StatusBadRequest) 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) return } // Return empty response (card will be removed from DOM) w.WriteHeader(http.StatusOK) } // HandleCreateTask creates a new Todoist task 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) return } content := r.FormValue("content") projectID := r.FormValue("project_id") if content == "" { http.Error(w, "Missing content", http.StatusBadRequest) 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) 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) 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{} } // Prepare data for template rendering data := struct { Tasks []models.Task Projects []models.Project }{ 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) } } // 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) return } taskID := r.FormValue("task_id") if taskID == "" { http.Error(w, "Missing task_id", http.StatusBadRequest) 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) 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) { 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.CompleteTask(ctx, id) case "trello": // Archive the card (closed = true) updates := map[string]interface{}{ "closed": true, } 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 complete task", http.StatusInternalServerError) log.Printf("Error completing atom (source=%s, id=%s): %v", source, id, 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" } // 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) } 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 (stays visible with strikethrough until refresh) w.Header().Set("Content-Type", "text/html") completedHTML := fmt.Sprintf(`
No bugs reported yet.
`) return } for _, bug := range bugs { fmt.Fprintf(w, `%s
%s