package handlers import ( "context" "log" "sort" "strings" "time" "task-dashboard/internal/api" "task-dashboard/internal/config" "task-dashboard/internal/models" "task-dashboard/internal/store" ) // BuildTimeline aggregates and normalizes data into a timeline structure func BuildTimeline(ctx context.Context, s *store.Store, tasksClient api.GoogleTasksAPI, start, end time.Time) ([]models.TimelineItem, error) { var items []models.TimelineItem now := config.Now() // 1. Fetch Tasks tasks, err := s.GetTasksByDateRange(start, end) if err != nil { return nil, err } for _, task := range tasks { if task.DueDate == nil { continue } item := models.TimelineItem{ ID: task.ID, Type: models.TimelineItemTypeTask, Title: task.Content, Time: *task.DueDate, Description: task.Description, URL: task.URL, OriginalItem: task, IsCompleted: task.Completed, Source: "todoist", } item.ComputeDaySection(now) items = append(items, item) } // 2. Fetch Meals - combine multiple items for same date+mealType meals, err := s.GetMealsByDateRange(start, end) if err != nil { return nil, err } // Group meals by date+mealType key 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) } // Create combined timeline items for each group for _, group := range mealGroups { if len(group) == 0 { continue } // Use first meal as base firstMeal := group[0] mealTime := firstMeal.Date // Apply Meal time defaults switch firstMeal.MealType { case "breakfast": mealTime = time.Date(mealTime.Year(), mealTime.Month(), mealTime.Day(), config.BreakfastHour, 0, 0, 0, mealTime.Location()) case "lunch": mealTime = time.Date(mealTime.Year(), mealTime.Month(), mealTime.Day(), config.LunchHour, 0, 0, 0, mealTime.Location()) case "dinner": mealTime = time.Date(mealTime.Year(), mealTime.Month(), mealTime.Day(), config.DinnerHour, 0, 0, 0, mealTime.Location()) default: mealTime = time.Date(mealTime.Year(), mealTime.Month(), mealTime.Day(), config.LunchHour, 0, 0, 0, mealTime.Location()) } // Combine recipe names with " + " var names []string for _, m := range group { names = append(names, m.RecipeName) } combinedTitle := strings.Join(names, " + ") item := models.TimelineItem{ ID: firstMeal.ID, Type: models.TimelineItemTypeMeal, Title: combinedTitle, Time: mealTime, URL: firstMeal.RecipeURL, // Use first meal's URL OriginalItem: group, // Store all meals IsCompleted: false, Source: "plantoeat", } item.ComputeDaySection(now) items = append(items, item) } // 3. Fetch Cards cards, err := s.GetCardsByDateRange(start, end) if err != nil { return nil, err } for _, card := range cards { if card.DueDate == nil { continue } item := models.TimelineItem{ ID: card.ID, Type: models.TimelineItemTypeCard, Title: card.Name, Time: *card.DueDate, URL: card.URL, OriginalItem: card, IsCompleted: false, // Cards in timeline are not completed (closed cards filtered out) Source: "trello", } item.ComputeDaySection(now) items = append(items, item) } // 4. Fetch Events from store cache (populated by fetchCalendarEvents) events, err := s.GetCalendarEventsByDateRange(start, end) if err != nil { log.Printf("Warning: failed to read cached calendar events: %v", err) } else { for _, event := range events { endTime := event.End item := models.TimelineItem{ ID: event.ID, Type: models.TimelineItemTypeEvent, Title: event.Summary, Time: event.Start, EndTime: &endTime, Description: event.Description, URL: event.HTMLLink, OriginalItem: event, IsCompleted: false, Source: "calendar", } item.ComputeDaySection(now) items = append(items, item) } } // 5. Fetch Google Tasks if tasksClient != nil { gTasks, err := tasksClient.GetTasksByDateRange(ctx, start, end) if err != nil { log.Printf("Warning: failed to fetch Google Tasks: %v", err) } else { log.Printf("Google Tasks: fetched %d tasks in date range", len(gTasks)) for _, gTask := range gTasks { // Tasks without due date are placed in today section taskTime := now if gTask.DueDate != nil { taskTime = *gTask.DueDate } item := models.TimelineItem{ ID: gTask.ID, Type: models.TimelineItemTypeGTask, Title: gTask.Title, Time: taskTime, Description: gTask.Notes, URL: gTask.URL, OriginalItem: gTask, IsCompleted: gTask.Completed, Source: "gtasks", ListID: gTask.ListID, } item.ComputeDaySection(now) items = append(items, item) } } } else { log.Printf("Google Tasks client not configured") } // Sort items by Time sort.Slice(items, func(i, j int) bool { return items[i].Time.Before(items[j].Time) }) return items, nil }