diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-01-12 09:27:16 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-01-12 09:27:16 -1000 |
| commit | 9fe0998436488537a8a2e8ffeefb0c4424b41c60 (patch) | |
| tree | ce877f04e60a187c2bd0e481e80298ec5e7cdf80 /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.go | 360 |
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 +} |
