summaryrefslogtreecommitdiff
path: root/internal/handlers/handlers.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-12 09:27:16 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-12 09:27:16 -1000
commit9fe0998436488537a8a2e8ffeefb0c4424b41c60 (patch)
treece877f04e60a187c2bd0e481e80298ec5e7cdf80 /internal/handlers/handlers.go
Initial commit: Personal Consolidation Dashboard (Phase 1 Complete)
Implemented a unified web dashboard aggregating tasks, notes, and meal planning: Core Features: - Trello integration (PRIMARY feature - boards, cards, lists) - Todoist integration (tasks and projects) - Obsidian integration (20 most recent notes) - PlanToEat integration (optional - 7-day meal planning) - Mobile-responsive web UI with auto-refresh (5 min) - SQLite caching with 5-minute TTL - AI agent endpoint with Bearer token authentication Technical Implementation: - Go 1.21+ backend with chi router - Interface-based API client design for testability - Parallel data fetching with goroutines - Graceful degradation (partial data on API failures) - .env file loading with godotenv - Comprehensive test coverage (9/9 tests passing) Bug Fixes: - Fixed .env file not being loaded at startup - Fixed nil pointer dereference with optional API clients (typed nil interface gotcha) Documentation: - START_HERE.md - Quick 5-minute setup guide - QUICKSTART.md - Fast track setup - SETUP_GUIDE.md - Detailed step-by-step instructions - PROJECT_SUMMARY.md - Complete project overview - CLAUDE.md - Guide for Claude Code instances - AI_AGENT_ACCESS.md - AI agent design document - AI_AGENT_SETUP.md - Claude.ai integration guide - TRELLO_AUTH_UPDATE.md - New Power-Up auth process Statistics: - Binary: 17MB - Code: 2,667 lines - Tests: 5 unit + 4 acceptance tests (all passing) - Dependencies: chi, sqlite3, godotenv Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/handlers/handlers.go')
-rw-r--r--internal/handlers/handlers.go360
1 files changed, 360 insertions, 0 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
new file mode 100644
index 0000000..6872ba7
--- /dev/null
+++ b/internal/handlers/handlers.go
@@ -0,0 +1,360 @@
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "html/template"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+
+ "task-dashboard/internal/api"
+ "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
+ obsidianClient api.ObsidianAPI
+ planToEatClient api.PlanToEatAPI
+ config *config.Config
+ templates *template.Template
+}
+
+// New creates a new Handler instance
+func New(store *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, obsidian api.ObsidianAPI, planToEat api.PlanToEatAPI, cfg *config.Config) *Handler {
+ // Parse templates
+ tmpl, err := template.ParseGlob("web/templates/*.html")
+ if err != nil {
+ log.Printf("Warning: failed to parse templates: %v", err)
+ }
+
+ return &Handler{
+ store: store,
+ todoistClient: todoist,
+ trelloClient: trello,
+ obsidianClient: obsidian,
+ planToEatClient: planToEat,
+ config: cfg,
+ templates: tmpl,
+ }
+}
+
+// HandleDashboard renders the main dashboard view
+func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ // Aggregate data from all sources
+ data, 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
+ }
+
+ 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)
+}
+
+// HandleGetNotes returns notes as JSON
+func (h *Handler) HandleGetNotes(w http.ResponseWriter, r *http.Request) {
+ notes, err := h.store.GetNotes(20)
+ if err != nil {
+ http.Error(w, "Failed to get notes", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(notes)
+}
+
+// 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)
+}
+
+// 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 {
+ data.Tasks = tasks
+ }
+ }()
+
+ // Fetch Obsidian notes (if configured)
+ if h.obsidianClient != nil {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ notes, err := h.fetchNotes(ctx, forceRefresh)
+ mu.Lock()
+ defer mu.Unlock()
+ if err != nil {
+ data.Errors = append(data.Errors, "Obsidian: "+err.Error())
+ } else {
+ data.Notes = notes
+ }
+ }()
+ }
+
+ // 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
+ }
+ }()
+ }
+
+ wg.Wait()
+
+ return data, nil
+}
+
+// fetchTasks fetches tasks from cache or API
+func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.Task, error) {
+ cacheKey := "todoist_tasks"
+
+ // Check cache validity
+ if !forceRefresh {
+ valid, err := h.store.IsCacheValid(cacheKey)
+ if err == nil && valid {
+ return h.store.GetTasks()
+ }
+ }
+
+ // Fetch from API
+ tasks, err := h.todoistClient.GetTasks(ctx)
+ 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
+ }
+
+ // Save to cache
+ if err := h.store.SaveTasks(tasks); err != nil {
+ log.Printf("Failed to save tasks 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 tasks, nil
+}
+
+// fetchNotes fetches notes from cache or filesystem
+func (h *Handler) fetchNotes(ctx context.Context, forceRefresh bool) ([]models.Note, error) {
+ cacheKey := "obsidian_notes"
+
+ // Check cache validity
+ if !forceRefresh {
+ valid, err := h.store.IsCacheValid(cacheKey)
+ if err == nil && valid {
+ return h.store.GetNotes(20)
+ }
+ }
+
+ // Fetch from filesystem
+ notes, err := h.obsidianClient.GetNotes(ctx, 20)
+ if err != nil {
+ // Try to return cached data even if stale
+ cachedNotes, cacheErr := h.store.GetNotes(20)
+ if cacheErr == nil && len(cachedNotes) > 0 {
+ return cachedNotes, nil
+ }
+ return nil, err
+ }
+
+ // Save to cache
+ if err := h.store.SaveNotes(notes); err != nil {
+ log.Printf("Failed to save notes 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 notes, nil
+}
+
+// fetchMeals fetches meals from cache or API
+func (h *Handler) fetchMeals(ctx context.Context, forceRefresh bool) ([]models.Meal, error) {
+ cacheKey := "plantoeat_meals"
+
+ // 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 := "trello_boards"
+
+ // 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
+ }
+
+ // 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
+}