package handlers import ( "context" "log" "sort" "strings" "time" "task-dashboard/internal/config" "task-dashboard/internal/models" "task-dashboard/internal/store" ) // setChainBadge populates item.ChainPosition/ChainTotal (1-indexed) when // task belongs to a chain. Only the currently-unlocked task in a chain ever // reaches BuildTimeline (locked tasks are excluded at the store layer), so // this runs at most once per chain per call -- no memoization needed. func setChainBadge(s *store.Store, item *models.TimelineItem, task models.Task) { if task.ChainID == "" { return } tasks, err := s.GetChainTasks(task.ChainID) if err != nil { return } item.ChainPosition = task.ChainPosition + 1 item.ChainTotal = len(tasks) } // BuildTimeline aggregates and normalizes data into a timeline structure func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([]models.TimelineItem, error) { var items []models.TimelineItem now := config.Now() // Fetch all projects once into a color lookup map projectColors := make(map[string]string) if projects, err := s.GetProjects(); err != nil { log.Printf("Warning: failed to read projects: %v", err) } else { for _, p := range projects { projectColors[p.ID] = p.Color } } // 1. Fetch Meals - combine multiple items for same date+mealType meals, err := s.GetMealsByDateRange(start, end) if err != nil { return nil, err } for _, cm := range groupMeals(meals) { mealTime := cm.Date switch cm.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()) } item := models.TimelineItem{ ID: cm.ID, Type: models.TimelineItemTypeMeal, Title: strings.Join(cm.RecipeNames, " + "), Time: mealTime, URL: cm.RecipeURL, OriginalItem: cm.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", RecurringEventID: event.RecurringEventID, } item.ComputeDaySection(now) items = append(items, item) } } // 5. Fetch Google Tasks from store cache gTasks, err := s.GetGoogleTasksByDateRange(start, end) if err != nil { log.Printf("Warning: failed to read cached Google Tasks: %v", err) } else { 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, // No due date -- flag IsAllDay like nativeUndated tasks below so // TimelineItemToWidgetItem leaves Start nil instead of pinning it // to "now", which otherwise lands it among scheduledEvents and // renders it via EventBlock/HourRow -- rows whose click handler // always opens the source URL/Google Calendar, not the task's // detail sheet. IsAllDay: gTask.DueDate == nil, } item.ComputeDaySection(now) items = append(items, item) } } // 6. Fetch native (doot-owned) tasks due within the range, plus any // overdue tasks (due before the range's start) so they still surface -- // GetNativeTasksByDateRange's lower bound would otherwise drop them // before ComputeDaySection ever gets a chance to mark them IsOverdue. nativeDated, err := s.GetNativeTasksByDateRange(start, end) if err != nil { log.Printf("Warning: failed to read native tasks: %v", err) } else { for _, task := range nativeDated { if task.DueDate == nil { continue } item := models.TimelineItem{ ID: task.ID, Type: models.TimelineItemTypeTask, Title: task.Content, Time: *task.DueDate, Description: task.Description, OriginalItem: task, IsCompleted: task.Completed, Source: "doot", ProjectColor: projectColors[task.ProjectID], } setChainBadge(s, &item, task) item.BucketState = task.BucketState item.ComputeDaySection(now) items = append(items, item) } } nativeOverdue, err := s.GetOverdueNativeTasks(start) if err != nil { log.Printf("Warning: failed to read overdue native tasks: %v", err) } else { for _, task := range nativeOverdue { if task.DueDate == nil { continue } item := models.TimelineItem{ ID: task.ID, Type: models.TimelineItemTypeTask, Title: task.Content, Time: *task.DueDate, Description: task.Description, OriginalItem: task, IsCompleted: task.Completed, Source: "doot", ProjectColor: projectColors[task.ProjectID], } setChainBadge(s, &item, task) item.BucketState = task.BucketState item.ComputeDaySection(now) items = append(items, item) } } nativeUndated, err := s.GetUndatedNativeTasks() if err != nil { log.Printf("Warning: failed to read undated native tasks: %v", err) } else { for _, task := range nativeUndated { item := models.TimelineItem{ ID: task.ID, Type: models.TimelineItemTypeTask, Title: task.Content, Time: now, Description: task.Description, OriginalItem: task, IsCompleted: task.Completed, Source: "doot", IsAllDay: true, ProjectColor: projectColors[task.ProjectID], } setChainBadge(s, &item, task) item.BucketState = task.BucketState 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 }