From 9fe0998436488537a8a2e8ffeefb0c4424b41c60 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 12 Jan 2026 09:27:16 -1000 Subject: 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 --- internal/handlers/ai_handlers.go | 273 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 internal/handlers/ai_handlers.go (limited to 'internal/handlers/ai_handlers.go') diff --git a/internal/handlers/ai_handlers.go b/internal/handlers/ai_handlers.go new file mode 100644 index 0000000..26c945e --- /dev/null +++ b/internal/handlers/ai_handlers.go @@ -0,0 +1,273 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "time" + + "task-dashboard/internal/models" +) + +// AISnapshotResponse matches the exact format requested by the user +type AISnapshotResponse struct { + GeneratedAt string `json:"generated_at"` + Tasks AITasksSection `json:"tasks"` + Meals AIMealsSection `json:"meals"` + Notes AINotesSection `json:"notes"` + TrelloBoards []AITrelloBoard `json:"trello_boards,omitempty"` +} + +type AITasksSection struct { + Today []AITask `json:"today"` + Overdue []AITask `json:"overdue"` + Next7Days []AITask `json:"next_7_days"` +} + +type AITask struct { + ID string `json:"id"` + Content string `json:"content"` + Priority int `json:"priority"` + Due *string `json:"due,omitempty"` + Project string `json:"project"` + Completed bool `json:"completed"` +} + +type AIMealsSection struct { + Today AIDayMeals `json:"today"` + Next7Days []AIDayMeals `json:"next_7_days"` +} + +type AIDayMeals struct { + Date string `json:"date"` + Breakfast string `json:"breakfast,omitempty"` + Lunch string `json:"lunch,omitempty"` + Dinner string `json:"dinner,omitempty"` + Snack string `json:"snack,omitempty"` +} + +type AINotesSection struct { + Recent []AINote `json:"recent"` +} + +type AINote struct { + Title string `json:"title"` + Modified string `json:"modified"` + Preview string `json:"preview"` + Path string `json:"path"` +} + +type AITrelloBoard struct { + ID string `json:"id"` + Name string `json:"name"` + Cards []AITrelloCard `json:"cards"` +} + +type AITrelloCard struct { + ID string `json:"id"` + Name string `json:"name"` + List string `json:"list"` + Due *string `json:"due,omitempty"` + URL string `json:"url"` +} + +// HandleAISnapshot returns a complete dashboard snapshot optimized for AI consumption +func (h *Handler) HandleAISnapshot(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Fetch all data (with caching) + data, err := h.aggregateData(ctx, false) + if err != nil { + respondJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "server_error", + "message": "Failed to fetch dashboard data", + }) + log.Printf("AI snapshot error: %v", err) + return + } + + // Build AI-optimized response + response := AISnapshotResponse{ + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + Tasks: buildAITasksSection(data.Tasks), + Meals: buildAIMealsSection(data.Meals), + Notes: buildAINotesSection(data.Notes), + TrelloBoards: buildAITrelloBoardsSection(data.Boards), + } + + respondJSON(w, http.StatusOK, response) +} + +// buildAITasksSection organizes tasks by time window +func buildAITasksSection(tasks []models.Task) AITasksSection { + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + next7Days := today.AddDate(0, 0, 7) + + section := AITasksSection{ + Today: []AITask{}, + Overdue: []AITask{}, + Next7Days: []AITask{}, + } + + for _, task := range tasks { + if task.Completed { + continue // Skip completed tasks + } + + aiTask := AITask{ + ID: task.ID, + Content: task.Content, + Priority: task.Priority, + Project: task.ProjectName, + Completed: task.Completed, + } + + if task.DueDate != nil { + dueStr := task.DueDate.UTC().Format(time.RFC3339) + aiTask.Due = &dueStr + + taskDay := time.Date(task.DueDate.Year(), task.DueDate.Month(), task.DueDate.Day(), 0, 0, 0, 0, task.DueDate.Location()) + + if taskDay.Before(today) { + section.Overdue = append(section.Overdue, aiTask) + } else if taskDay.Equal(today) { + section.Today = append(section.Today, aiTask) + } else if taskDay.Before(next7Days) { + section.Next7Days = append(section.Next7Days, aiTask) + } + } + } + + return section +} + +// buildAIMealsSection organizes meals by day +func buildAIMealsSection(meals []models.Meal) AIMealsSection { + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + next7Days := today.AddDate(0, 0, 7) + + section := AIMealsSection{ + Today: AIDayMeals{Date: today.Format("2006-01-02")}, + Next7Days: []AIDayMeals{}, + } + + // Group meals by date + mealsByDate := make(map[string]*AIDayMeals) + + for _, meal := range meals { + mealDay := time.Date(meal.Date.Year(), meal.Date.Month(), meal.Date.Day(), 0, 0, 0, 0, meal.Date.Location()) + + if mealDay.Before(today) || mealDay.After(next7Days) { + continue // Skip meals outside our window + } + + dateStr := mealDay.Format("2006-01-02") + + if _, exists := mealsByDate[dateStr]; !exists { + mealsByDate[dateStr] = &AIDayMeals{Date: dateStr} + } + + dayMeals := mealsByDate[dateStr] + + switch meal.MealType { + case "breakfast": + dayMeals.Breakfast = meal.RecipeName + case "lunch": + dayMeals.Lunch = meal.RecipeName + case "dinner": + dayMeals.Dinner = meal.RecipeName + case "snack": + dayMeals.Snack = meal.RecipeName + } + } + + // Assign today's meals + if todayMeals, exists := mealsByDate[today.Format("2006-01-02")]; exists { + section.Today = *todayMeals + } + + // Collect next 7 days (excluding today) + for i := 1; i <= 7; i++ { + day := today.AddDate(0, 0, i) + dateStr := day.Format("2006-01-02") + if dayMeals, exists := mealsByDate[dateStr]; exists { + section.Next7Days = append(section.Next7Days, *dayMeals) + } + } + + return section +} + +// buildAINotesSection returns the 10 most recent notes with previews +func buildAINotesSection(notes []models.Note) AINotesSection { + section := AINotesSection{ + Recent: []AINote{}, + } + + // Limit to 10 most recent + limit := 10 + if len(notes) < limit { + limit = len(notes) + } + + for i := 0; i < limit; i++ { + note := notes[i] + + // Limit preview to 150 chars + preview := note.Content + if len(preview) > 150 { + preview = preview[:150] + "..." + } + + section.Recent = append(section.Recent, AINote{ + Title: note.Title, + Modified: note.Modified.UTC().Format(time.RFC3339), + Preview: preview, + Path: note.Path, + }) + } + + return section +} + +// buildAITrelloBoardsSection formats Trello boards for AI +func buildAITrelloBoardsSection(boards []models.Board) []AITrelloBoard { + aiBoards := []AITrelloBoard{} + + for _, board := range boards { + aiBoard := AITrelloBoard{ + ID: board.ID, + Name: board.Name, + Cards: []AITrelloCard{}, + } + + for _, card := range board.Cards { + aiCard := AITrelloCard{ + ID: card.ID, + Name: card.Name, + List: card.ListName, + URL: card.URL, + } + + if card.DueDate != nil { + dueStr := card.DueDate.UTC().Format(time.RFC3339) + aiCard.Due = &dueStr + } + + aiBoard.Cards = append(aiBoard.Cards, aiCard) + } + + aiBoards = append(aiBoards, aiBoard) + } + + return aiBoards +} + +// respondJSON sends a JSON response +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} -- cgit v1.2.3