package handlers import ( "context" "fmt" "html/template" "log" "net/http" "net/url" "path/filepath" "sort" "strings" "sync" "time" "github.com/go-chi/chi/v5" "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 googleTasksClient api.GoogleTasksAPI 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, googleTasks api.GoogleTasksAPI, cfg *config.Config) *Handler { // Template functions funcMap := template.FuncMap{ "subtract": func(a, b int) int { return a - b }, } // Parse templates including partials tmpl, err := template.New("").Funcs(funcMap).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, googleTasksClient: googleTasks, 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 = "timeline" } // 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) { data, err := h.aggregateData(r.Context(), true) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to refresh data", err) return } 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 { JSONError(w, http.StatusInternalServerError, "Failed to get tasks", err) return } JSONResponse(w, tasks) } // HandleGetMeals returns meals as JSON func (h *Handler) HandleGetMeals(w http.ResponseWriter, r *http.Request) { startDate := config.Now() endDate := startDate.AddDate(0, 0, 7) meals, err := h.store.GetMeals(startDate, endDate) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to get meals", err) return } 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 { JSONError(w, http.StatusInternalServerError, "Failed to get boards", err) return } JSONResponse(w, boards) } // HandleGetShoppingList returns PlanToEat shopping list as JSON func (h *Handler) HandleGetShoppingList(w http.ResponseWriter, r *http.Request) { if h.planToEatClient == nil { JSONError(w, http.StatusServiceUnavailable, "PlanToEat not configured", nil) return } items, err := h.planToEatClient.GetShoppingList(r.Context()) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to get shopping list", err) return } JSONResponse(w, items) } // HandleTasksTab renders the tasks tab content (Trello + Todoist + PlanToEat) func (h *Handler) HandleTasksTab(w http.ResponseWriter, r *http.Request) { data, err := h.aggregateData(r.Context(), false) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to load tasks", err) return } 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) { data, err := h.aggregateData(r.Context(), true) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to refresh", err) return } HTMLResponse(w, h.templates, "tasks-tab", data) } // 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: config.Now(), Errors: make([]string, 0), } var wg sync.WaitGroup var mu sync.Mutex // 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 { log.Printf("ERROR [%s]: %v", name, err) mu.Lock() data.Errors = append(data.Errors, name+": "+err.Error()) mu.Unlock() } }() } fetch("Trello", func() error { boards, err := h.fetchBoards(ctx, forceRefresh) if err == nil { mu.Lock() data.Boards = boards mu.Unlock() } return err }) fetch("Todoist", func() error { tasks, err := h.fetchTasks(ctx, forceRefresh) if err == nil { sortTasksByUrgency(tasks) mu.Lock() data.Tasks = tasks mu.Unlock() } return err }) fetch("Projects", func() error { projects, err := h.todoistClient.GetProjects(ctx) if err == nil { mu.Lock() data.Projects = projects mu.Unlock() } return err }) if h.planToEatClient != nil { fetch("PlanToEat", func() error { meals, err := h.fetchMeals(ctx, forceRefresh) if err == nil { mu.Lock() data.Meals = meals mu.Unlock() } return err }) } if h.googleCalendarClient != nil { fetch("Google Calendar", func() error { events, err := h.googleCalendarClient.GetUpcomingEvents(ctx, 10) if err == nil { mu.Lock() data.Events = events mu.Unlock() } return err }) } wg.Wait() // 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 { if card.DueDate != nil || isActionableList(card.ListName) { tasks = append(tasks, card) } } } 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) } } if tasks[i].DueDate != nil && tasks[j].DueDate == nil { return true } if tasks[i].DueDate == nil && tasks[j].DueDate != nil { return false } return tasks[i].BoardName < tasks[j].BoardName }) return tasks } // 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 { // 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: item.IsCompleted, Labels: item.Labels, URL: fmt.Sprintf("https://todoist.com/app/task/%s", item.ID), } } // fetchMeals fetches meals from cache or API func (h *Handler) fetchMeals(ctx context.Context, forceRefresh bool) ([]models.Meal, error) { startDate := config.Now() endDate := startDate.AddDate(0, 0, 7) 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 fetcher.FetchWithCache(ctx, forceRefresh) } // fetchBoards fetches Trello boards from cache or API func (h *Handler) fetchBoards(ctx context.Context, forceRefresh bool) ([]models.Board, error) { 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 fetcher.FetchWithCache(ctx, forceRefresh) } // HandleCreateCard creates a new Trello card func (h *Handler) HandleCreateCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } boardID := r.FormValue("board_id") listID := r.FormValue("list_id") name := r.FormValue("name") if boardID == "" || listID == "" || name == "" { JSONError(w, http.StatusBadRequest, "Missing required fields", nil) return } if _, err := h.trelloClient.CreateCard(ctx, listID, name, "", nil); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to create card", err) return } data, err := h.aggregateData(ctx, true) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to refresh data", err) return } var targetBoard *models.Board for i := range data.Boards { if data.Boards[i].ID == boardID { targetBoard = &data.Boards[i] break } } if targetBoard == nil { JSONError(w, http.StatusNotFound, "Board not found", nil) return } HTMLResponse(w, h.templates, "trello-board", targetBoard) } // HandleCompleteCard marks a Trello card as complete func (h *Handler) HandleCompleteCard(w http.ResponseWriter, r *http.Request) { if !parseFormOr400(w, r) { return } cardID := r.FormValue("card_id") if cardID == "" { JSONError(w, http.StatusBadRequest, "Missing card_id", nil) return } 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 } w.WriteHeader(http.StatusOK) } // HandleCreateTask creates a new Todoist task func (h *Handler) HandleCreateTask(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } content := r.FormValue("content") projectID := r.FormValue("project_id") if content == "" { JSONError(w, http.StatusBadRequest, "Missing content", nil) return } if _, err := h.todoistClient.CreateTask(ctx, content, projectID, nil, 0); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to create task", err) return } tasks, err := h.fetchTasks(ctx, true) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to refresh tasks", err) return } projects, _ := h.todoistClient.GetProjects(ctx) data := struct { Tasks []models.Task Projects []models.Project }{Tasks: tasks, Projects: projects} HTMLResponse(w, h.templates, "todoist-tasks", data) } // HandleCompleteTask marks a Todoist task as complete func (h *Handler) HandleCompleteTask(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } taskID := r.FormValue("task_id") if taskID == "" { JSONError(w, http.StatusBadRequest, "Missing task_id", nil) return } if err := h.todoistClient.CompleteTask(r.Context(), taskID); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to complete task", err) return } 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 { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } id := r.FormValue("id") source := r.FormValue("source") if id == "" || source == "" { JSONError(w, http.StatusBadRequest, "Missing id or source", nil) return } var err error switch source { case "todoist": if complete { err = h.todoistClient.CompleteTask(ctx, id) } else { err = h.todoistClient.ReopenTask(ctx, id) } case "trello": err = h.trelloClient.UpdateCard(ctx, id, map[string]interface{}{"closed": complete}) case "bug": // Bug IDs are prefixed with "bug-", extract the numeric ID var bugID int64 if _, parseErr := fmt.Sscanf(id, "bug-%d", &bugID); parseErr != nil { JSONError(w, http.StatusBadRequest, "Invalid bug ID format", parseErr) return } if complete { err = h.store.ResolveBug(bugID) } else { err = h.store.UnresolveBug(bugID) } case "gtasks": // Google Tasks - need list ID from form or use default listID := r.FormValue("listId") if listID == "" { listID = "@default" } if h.googleTasksClient != nil { if complete { err = h.googleTasksClient.CompleteTask(ctx, listID, id) } else { err = h.googleTasksClient.UncompleteTask(ctx, listID, id) } } else { JSONError(w, http.StatusServiceUnavailable, "Google Tasks not configured", nil) return } default: JSONError(w, http.StatusBadRequest, "Unknown source: "+source, nil) return } if err != nil { action := "complete" if !complete { action = "reopen" } JSONError(w, http.StatusInternalServerError, "Failed to "+action+" task", err) return } if complete { // Get task details before removing from cache title, dueDate := h.getAtomDetails(id, source) // Log to completed tasks _ = h.store.SaveCompletedTask(source, id, title, dueDate) // Remove from local cache switch source { case "todoist": _ = h.store.DeleteTask(id) case "trello": _ = h.store.DeleteCard(id) } // Return completed task HTML with uncomplete option data := struct { ID string Source string Title string }{id, source, title} HTMLResponse(w, h.templates, "completed-atom", data) } else { // Invalidate cache to force refresh switch source { case "todoist": _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks) case "trello": _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) } // Don't swap empty response - just trigger refresh w.Header().Set("HX-Reswap", "none") w.Header().Set("HX-Trigger", "refresh-tasks") w.WriteHeader(http.StatusOK) } } // getAtomDetails retrieves title and due date for a task/card/bug from the store func (h *Handler) getAtomDetails(id, source string) (string, *time.Time) { switch source { case "todoist": if tasks, err := h.store.GetTasks(); err == nil { for _, t := range tasks { if t.ID == id { return t.Content, t.DueDate } } } case "trello": if boards, err := h.store.GetBoards(); err == nil { for _, b := range boards { for _, c := range b.Cards { if c.ID == id { return c.Name, c.DueDate } } } } case "bug": if bugs, err := h.store.GetBugs(); err == nil { var bugID int64 if _, err := fmt.Sscanf(id, "bug-%d", &bugID); err == nil { for _, b := range bugs { if b.ID == bugID { return b.Description, nil } } } } case "gtasks": // Google Tasks don't have local cache, return generic title return "Google Task", nil } return "Task", nil } // getAtomTitle retrieves the title for a task/card/bug from the store (legacy) func (h *Handler) getAtomTitle(id, source string) string { title, _ := h.getAtomDetails(id, source) return title } // HandleUnifiedAdd creates a task in Todoist or a card in Trello from the Quick Add form func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } title := r.FormValue("title") source := r.FormValue("source") dueDateStr := r.FormValue("due_date") if title == "" { JSONError(w, http.StatusBadRequest, "Title is required", nil) return } var dueDate *time.Time if dueDateStr != "" { if parsed, err := config.ParseDateInDisplayTZ(dueDateStr); err == nil { dueDate = &parsed } } switch source { case "todoist": if _, err := h.todoistClient.CreateTask(ctx, title, "", dueDate, 1); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to create Todoist task", err) return } _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks) case "trello": listID := r.FormValue("list_id") if listID == "" { JSONError(w, http.StatusBadRequest, "List is required for Trello", nil) return } if _, err := h.trelloClient.CreateCard(ctx, listID, title, "", dueDate); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to create Trello card", err) return } _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) default: JSONError(w, http.StatusBadRequest, "Invalid source", nil) return } 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) { boardID := r.URL.Query().Get("board_id") if boardID == "" { JSONError(w, http.StatusBadRequest, "board_id is required", nil) return } lists, err := h.trelloClient.GetLists(r.Context(), boardID) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to fetch lists", err) return } w.Header().Set("Content-Type", "text/html") for _, list := range lists { _, _ = fmt.Fprintf(w, ``, template.HTMLEscapeString(list.ID), template.HTMLEscapeString(list.Name)) } } // HandleGetBugs returns the list of reported bugs func (h *Handler) HandleGetBugs(w http.ResponseWriter, r *http.Request) { bugs, err := h.store.GetBugs() if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to fetch bugs", err) return } w.Header().Set("Content-Type", "text/html") if len(bugs) == 0 { _, _ = fmt.Fprint(w, `

No bugs reported yet.

`) return } for _, bug := range bugs { _, _ = fmt.Fprintf(w, `

%s

%s

`, template.HTMLEscapeString(bug.Description), bug.CreatedAt.Format("Jan 2, 3:04 PM")) } } // HandleReportBug saves a new bug report func (h *Handler) HandleReportBug(w http.ResponseWriter, r *http.Request) { if !parseFormOr400(w, r) { return } description := strings.TrimSpace(r.FormValue("description")) if description == "" { JSONError(w, http.StatusBadRequest, "Description is required", nil) return } if err := h.store.SaveBug(description); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to save bug", err) return } h.HandleGetBugs(w, r) } // HandleGetTaskDetail returns task details as HTML for modal func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") source := r.URL.Query().Get("source") if id == "" || source == "" { JSONError(w, http.StatusBadRequest, "Missing id or source", nil) return } var title, description string switch source { case "todoist": if tasks, err := h.store.GetTasks(); err == nil { for _, t := range tasks { if t.ID == id { title, description = t.Content, t.Description 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 } } } } } html := fmt.Sprintf(`

%s

`, template.HTMLEscapeString(title), template.HTMLEscapeString(id), template.HTMLEscapeString(source), template.HTMLEscapeString(description)) 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 { JSONError(w, http.StatusInternalServerError, "Failed to get boards", err) return } var shoppingBoardID string for _, b := range boards { if strings.EqualFold(b.Name, "Shopping") { shoppingBoardID = b.ID break } } if shoppingBoardID == "" { JSONError(w, http.StatusNotFound, "Shopping board not found", nil) return } lists, err := h.trelloClient.GetLists(r.Context(), shoppingBoardID) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to get lists", err) return } w.Header().Set("Content-Type", "text/html") for _, list := range lists { _, _ = fmt.Fprintf(w, ``, template.HTMLEscapeString(list.ID), template.HTMLEscapeString(list.Name)) } } // HandleUpdateTask updates a task description func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } id := r.FormValue("id") source := r.FormValue("source") description := r.FormValue("description") if id == "" || source == "" { JSONError(w, http.StatusBadRequest, "Missing id or source", nil) return } var err error switch source { case "todoist": err = h.todoistClient.UpdateTask(r.Context(), id, map[string]interface{}{"description": description}) case "trello": err = h.trelloClient.UpdateCard(r.Context(), id, map[string]interface{}{"desc": description}) default: JSONError(w, http.StatusBadRequest, "Unknown source", nil) return } if err != nil { 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 + Bugs) func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { atoms, boards, err := BuildUnifiedAtomList(h.store) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to fetch tasks", err) return } SortAtomsByUrgency(atoms) currentAtoms, futureAtoms := PartitionAtomsByTime(atoms) data := struct { Atoms []models.Atom FutureAtoms []models.Atom Boards []models.Board Today string }{ Atoms: currentAtoms, FutureAtoms: futureAtoms, Boards: boards, Today: config.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) { today := config.Today() 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) } // CombinedMeal represents multiple meals combined for same date+mealType type CombinedMeal struct { RecipeNames []string Date time.Time MealType string RecipeURL string // URL of first meal } // HandleTabMeals renders the Meals tab (PlanToEat) func (h *Handler) HandleTabMeals(w http.ResponseWriter, r *http.Request) { startDate := config.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 } // Group meals by date+mealType type mealKey struct { date string mealType string } mealGroups := make(map[mealKey][]models.Meal) for _, meal := range meals { key := mealKey{ date: meal.Date.Format("2006-01-02"), mealType: meal.MealType, } mealGroups[key] = append(mealGroups[key], meal) } // Convert to combined meals var combined []CombinedMeal for _, group := range mealGroups { if len(group) == 0 { continue } cm := CombinedMeal{ Date: group[0].Date, MealType: group[0].MealType, RecipeURL: group[0].RecipeURL, } for _, m := range group { cm.RecipeNames = append(cm.RecipeNames, m.RecipeName) } combined = append(combined, cm) } // Sort by date then meal type order sort.Slice(combined, func(i, j int) bool { if !combined[i].Date.Equal(combined[j].Date) { return combined[i].Date.Before(combined[j].Date) } return mealTypeOrder(combined[i].MealType) < mealTypeOrder(combined[j].MealType) }) HTMLResponse(w, h.templates, "meals-tab", struct{ Meals []CombinedMeal }{combined}) } // mealTypeOrder returns sort order for meal types func mealTypeOrder(mealType string) int { switch mealType { case "breakfast": return 0 case "lunch": return 1 case "dinner": return 2 default: return 3 } } // HandleTabShopping renders the Shopping tab (Trello Shopping board + PlanToEat + User items) func (h *Handler) HandleTabShopping(w http.ResponseWriter, r *http.Request) { ctx := r.Context() stores := h.aggregateShoppingLists(ctx) grouped := r.URL.Query().Get("grouped") != "false" // Default to grouped HTMLResponse(w, h.templates, "shopping-tab", struct { Stores []models.ShoppingStore Grouped bool }{stores, grouped}) } // HandleShoppingQuickAdd adds a user shopping item func (h *Handler) HandleShoppingQuickAdd(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } name := strings.TrimSpace(r.FormValue("name")) storeName := strings.TrimSpace(r.FormValue("store")) mode := r.FormValue("mode") // "shopping-mode" if from shopping mode page if name == "" { JSONError(w, http.StatusBadRequest, "Name is required", nil) return } if storeName == "" { storeName = "Quick Add" } if err := h.store.SaveUserShoppingItem(name, storeName); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to save item", err) return } ctx := r.Context() allStores := h.aggregateShoppingLists(ctx) // If called from shopping mode, return just the items for that store if mode == "shopping-mode" { var items []models.UnifiedShoppingItem for _, store := range allStores { if strings.EqualFold(store.Name, storeName) { for _, cat := range store.Categories { items = append(items, cat.Items...) } } } // Sort: unchecked first, then checked sort.Slice(items, func(i, j int) bool { if items[i].Checked != items[j].Checked { return !items[i].Checked } return items[i].Name < items[j].Name }) HTMLResponse(w, h.templates, "shopping-mode-items", struct { StoreName string Items []models.UnifiedShoppingItem }{storeName, items}) return } // Return refreshed shopping tab HTMLResponse(w, h.templates, "shopping-tab", struct { Stores []models.ShoppingStore Grouped bool }{allStores, true}) } // HandleShoppingToggle toggles a shopping item's checked state func (h *Handler) HandleShoppingToggle(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } id := r.FormValue("id") source := r.FormValue("source") checked := r.FormValue("checked") == "true" switch source { case "user": var userID int64 if _, err := fmt.Sscanf(id, "user-%d", &userID); err != nil { JSONError(w, http.StatusBadRequest, "Invalid user item ID", err) return } if err := h.store.ToggleUserShoppingItem(userID, checked); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err) return } case "trello", "plantoeat": // Store checked state locally for external sources if err := h.store.SetShoppingItemChecked(source, id, checked); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err) return } default: JSONError(w, http.StatusBadRequest, "Unknown source", nil) return } // Return refreshed shopping tab stores := h.aggregateShoppingLists(r.Context()) HTMLResponse(w, h.templates, "shopping-tab", struct { Stores []models.ShoppingStore Grouped bool }{stores, true}) } // HandleShoppingMode renders the focused shopping mode for a single store func (h *Handler) HandleShoppingMode(w http.ResponseWriter, r *http.Request) { storeName := chi.URLParam(r, "store") if storeName == "" { http.Redirect(w, r, "/?tab=shopping", http.StatusFound) return } // URL decode the store name storeName, _ = url.QueryUnescape(storeName) ctx := r.Context() allStores := h.aggregateShoppingLists(ctx) // Find the requested store var items []models.UnifiedShoppingItem var storeNames []string for _, store := range allStores { storeNames = append(storeNames, store.Name) if strings.EqualFold(store.Name, storeName) { // Flatten categories into single item list for _, cat := range store.Categories { items = append(items, cat.Items...) } } } // Sort: unchecked first, then checked (both alphabetically within their group) sort.Slice(items, func(i, j int) bool { if items[i].Checked != items[j].Checked { return !items[i].Checked // unchecked items first } return items[i].Name < items[j].Name }) data := struct { StoreName string Items []models.UnifiedShoppingItem StoreNames []string }{ StoreName: storeName, Items: items, StoreNames: storeNames, } HTMLResponse(w, h.templates, "shopping-mode.html", data) } // HandleShoppingModeToggle toggles an item in shopping mode and returns updated list func (h *Handler) HandleShoppingModeToggle(w http.ResponseWriter, r *http.Request) { storeName := chi.URLParam(r, "store") if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } id := r.FormValue("id") source := r.FormValue("source") checked := r.FormValue("checked") == "true" // Toggle the item switch source { case "user": var userID int64 if _, err := fmt.Sscanf(id, "user-%d", &userID); err != nil { JSONError(w, http.StatusBadRequest, "Invalid user item ID", err) return } if err := h.store.ToggleUserShoppingItem(userID, checked); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err) return } case "trello", "plantoeat": if err := h.store.SetShoppingItemChecked(source, id, checked); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err) return } } // URL decode the store name storeName, _ = url.QueryUnescape(storeName) // Return updated item list partial ctx := r.Context() allStores := h.aggregateShoppingLists(ctx) var items []models.UnifiedShoppingItem for _, store := range allStores { if strings.EqualFold(store.Name, storeName) { for _, cat := range store.Categories { items = append(items, cat.Items...) } } } // Sort: unchecked first, then checked sort.Slice(items, func(i, j int) bool { if items[i].Checked != items[j].Checked { return !items[i].Checked } return items[i].Name < items[j].Name }) HTMLResponse(w, h.templates, "shopping-mode-items", struct { StoreName string Items []models.UnifiedShoppingItem }{storeName, items}) } // aggregateShoppingLists combines Trello, PlanToEat, and user shopping items by store func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingStore { storeMap := make(map[string]map[string][]models.UnifiedShoppingItem) // Fetch locally stored checked states for external sources trelloChecks, _ := h.store.GetShoppingItemChecks("trello") plantoeatChecks, _ := h.store.GetShoppingItemChecks("plantoeat") // 1. Fetch Trello "Shopping" board cards boards, err := h.store.GetBoards() if err != nil { log.Printf("ERROR [Shopping/Trello]: %v", err) } else { for _, board := range boards { if strings.EqualFold(board.Name, "Shopping") { log.Printf("DEBUG [Shopping]: Found Shopping board with %d cards", len(board.Cards)) for _, card := range board.Cards { storeName := card.ListName if storeName == "" { storeName = "Uncategorized" } if storeMap[storeName] == nil { storeMap[storeName] = make(map[string][]models.UnifiedShoppingItem) } item := models.UnifiedShoppingItem{ ID: card.ID, Name: card.Name, Store: storeName, Source: "trello", Checked: trelloChecks[card.ID], } storeMap[storeName][""] = append(storeMap[storeName][""], item) } } } } // 2. Fetch PlanToEat shopping list if h.planToEatClient != nil { items, err := h.planToEatClient.GetShoppingList(ctx) if err != nil { log.Printf("ERROR [Shopping/PlanToEat]: %v", err) } else { log.Printf("DEBUG [Shopping/PlanToEat]: Found %d items", len(items)) for _, item := range items { storeName := item.Store if storeName == "" { storeName = "PlanToEat" } if storeMap[storeName] == nil { storeMap[storeName] = make(map[string][]models.UnifiedShoppingItem) } // Use locally stored check state if available, otherwise use scraped state checked := item.Checked if localChecked, ok := plantoeatChecks[item.ID]; ok { checked = localChecked } unified := models.UnifiedShoppingItem{ ID: item.ID, Name: item.Name, Quantity: item.Quantity, Category: item.Category, Store: storeName, Source: "plantoeat", Checked: checked, } storeMap[storeName][item.Category] = append(storeMap[storeName][item.Category], unified) } } } else { log.Printf("DEBUG [Shopping/PlanToEat]: Client not configured") } // 3. Fetch user-added shopping items userItems, err := h.store.GetUserShoppingItems() if err != nil { log.Printf("ERROR [Shopping/User]: %v", err) } else { for _, item := range userItems { storeName := item.Store if storeName == "" { storeName = "Quick Add" } if storeMap[storeName] == nil { storeMap[storeName] = make(map[string][]models.UnifiedShoppingItem) } unified := models.UnifiedShoppingItem{ ID: fmt.Sprintf("user-%d", item.ID), Name: item.Name, Store: storeName, Source: "user", Checked: item.Checked, } storeMap[storeName][""] = append(storeMap[storeName][""], unified) } } // 4. Convert map to sorted slice var stores []models.ShoppingStore for storeName, categories := range storeMap { store := models.ShoppingStore{Name: storeName} // Get sorted category names var catNames []string for catName := range categories { catNames = append(catNames, catName) } sort.Strings(catNames) for _, catName := range catNames { items := categories[catName] sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) store.Categories = append(store.Categories, models.ShoppingCategory{ Name: catName, Items: items, }) } stores = append(stores, store) } // Sort stores alphabetically sort.Slice(stores, func(i, j int) bool { return stores[i].Name < stores[j].Name }) return stores } // HandleTabConditions renders the Conditions tab with live feeds func (h *Handler) HandleTabConditions(w http.ResponseWriter, r *http.Request) { HTMLResponse(w, h.templates, "conditions-tab", nil) } // HandleConditionsPage renders the standalone Conditions page with live feeds func (h *Handler) HandleConditionsPage(w http.ResponseWriter, r *http.Request) { if err := h.templates.ExecuteTemplate(w, "conditions.html", nil); err != nil { http.Error(w, "Failed to render conditions page", http.StatusInternalServerError) log.Printf("Error rendering conditions page: %v", err) } } // 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") } // 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 }