package handlers import ( "context" "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, calendarClient api.GoogleCalendarAPI, 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 if calendarClient != nil { events, err := calendarClient.GetEventsByDateRange(ctx, start, end) if err == nil { 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, // Events don't have completion status Source: "calendar", } item.ComputeDaySection(now) items = append(items, item) } } } // Sort items by Time sort.Slice(items, func(i, j int) bool { return items[i].Time.Before(items[j].Time) }) return items, nil }