package handlers
import (
"context"
"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 = "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 := time.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: time.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 := time.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 err := r.ParseForm(); err != nil {
JSONError(w, http.StatusBadRequest, "Failed to parse form", err)
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)
}
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 title before removing from cache
title := h.getAtomTitle(id, source)
// 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
completedHTML := fmt.Sprintf(`
`, template.HTMLEscapeString(id), template.HTMLEscapeString(source), template.HTMLEscapeString(title))
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)
}
// 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)
}
}
// getAtomTitle retrieves the title for a task/card/bug from the store
func (h *Handler) getAtomTitle(id, source string) string {
switch source {
case "todoist":
if tasks, err := h.store.GetTasks(); err == nil {
for _, t := range tasks {
if t.ID == id {
return t.Content
}
}
}
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
}
}
}
}
case "bug":
if bugs, err := h.store.GetBugs(); err == nil {
var bugID int64
fmt.Sscanf(id, "bug-%d", &bugID)
for _, b := range bugs {
if b.ID == bugID {
return b.Description
}
}
}
}
return "Task"
}
// 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 := time.ParseInLocation("2006-01-02", dueDateStr, time.Local); 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, ``, list.ID, 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, ``, 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 err := r.ParseForm(); err != nil {
JSONError(w, http.StatusBadRequest, "Invalid form data", err)
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, ``, list.ID, 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) {
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
}
bugs, err := h.store.GetUnresolvedBugs()
if err != nil {
log.Printf("Warning: failed to fetch bugs: %v", err)
bugs = nil
}
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))
}
}
}
// Add unresolved bugs as atoms
for _, bug := range bugs {
atoms = append(atoms, models.BugToAtom(models.Bug{
ID: bug.ID,
Description: bug.Description,
CreatedAt: bug.CreatedAt,
}))
}
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 {
// Don't show recurring tasks until the day they're due
if a.IsRecurring && a.IsFuture {
continue
}
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})
}
// 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)
HTMLResponse(w, h.templates, "shopping-tab", struct{ Stores []models.ShoppingStore }{stores})
}
// 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"))
store := strings.TrimSpace(r.FormValue("store"))
if name == "" {
JSONError(w, http.StatusBadRequest, "Name is required", nil)
return
}
if store == "" {
store = "Quick Add"
}
if err := h.store.SaveUserShoppingItem(name, store); err != nil {
JSONError(w, http.StatusInternalServerError, "Failed to save item", err)
return
}
// Return refreshed shopping tab
ctx := r.Context()
stores := h.aggregateShoppingLists(ctx)
HTMLResponse(w, h.templates, "shopping-tab", struct{ Stores []models.ShoppingStore }{stores})
}
// 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"
if source == "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
}
}
// Note: Trello and PlanToEat toggle would need their own API calls
// Return updated item HTML
checkedClass := ""
checkedAttr := ""
textClass := "text-white"
if checked {
checkedClass = "opacity-50"
checkedAttr = "checked"
textClass = "line-through text-white/40"
}
html := fmt.Sprintf(`
%s
user
`, checkedClass, checkedAttr, template.HTMLEscapeString(id), template.HTMLEscapeString(source), !checked, textClass, template.HTMLEscapeString(r.FormValue("name")))
HTMLString(w, html)
}
// 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)
// 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",
}
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)
}
unified := models.UnifiedShoppingItem{
ID: item.ID,
Name: item.Name,
Quantity: item.Quantity,
Category: item.Category,
Store: storeName,
Source: "plantoeat",
Checked: item.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")
}
// 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
}