package handlers import ( "context" "log" "sort" "task-dashboard/internal/api" "task-dashboard/internal/models" "task-dashboard/internal/store" ) // BuildUnifiedAtomList creates a list of atoms from tasks, cards, and google tasks func BuildUnifiedAtomList(s *store.Store, claudomator api.ClaudomatorClient) ([]models.Atom, []models.Board, error) { boards, err := s.GetBoards() if err != nil { return nil, nil, err } gTasks, err := s.GetGoogleTasks() if err != nil { // Log but don't fail if gtasks fails (might be new/not configured) log.Printf("Warning: failed to fetch cached google tasks: %v", err) } nativeTasks, err := s.GetNativeTasks() if err != nil { log.Printf("Warning: failed to fetch native tasks: %v", err) } atoms := make([]models.Atom, 0, len(gTasks)+len(nativeTasks)) // Add native doot tasks (GetNativeTasks already filters completed=0). // Chain tasks (locked or unlocked) and dormant bucket-pool items are // deliberately excluded here. Chains get their own "Chains" section // (see BuildChainSummaries) that owns the whole lifecycle -- viewing // every step, completing the current one, pause/resume/abandon -- in // one place, rather than the current step also duplicating into this // flat list and the locked steps cluttering it with non-actionable // cards. Dormant bucket items aren't actionable until picked, per the // bucket design. for _, task := range nativeTasks { if task.ChainID != "" { continue } if task.BucketState == "dormant" { continue } atoms = append(atoms, models.NativeTaskToAtom(task)) } // Add incomplete google tasks for _, gTask := range gTasks { if !gTask.Completed { atoms = append(atoms, models.GoogleTaskToAtom(gTask)) } } // Add cards with due dates or from actionable lists for _, board := range boards { for _, card := range board.Cards { if card.DueDate != nil || isActionableList(card.ListName) { atoms = append(atoms, models.CardToAtom(card)) } } } if claudomator != nil { stories, err := claudomator.GetActiveStories(context.Background()) if err != nil { log.Printf("Warning: failed to fetch Claudomator stories: %v", err) } else { for _, s := range stories { atoms = append(atoms, models.StoryToAtom(s)) } } } // Compute UI fields for all atoms for i := range atoms { atoms[i].ComputeUIFields() } return atoms, boards, nil } // SortAtomsByUrgency sorts atoms by urgency tier, then due date, then priority func SortAtomsByUrgency(atoms []models.Atom) { sort.SliceStable(atoms, func(i, j int) bool { tierI := atomUrgencyTier(atoms[i]) tierJ := atomUrgencyTier(atoms[j]) if tierI != tierJ { return tierI < tierJ } if atoms[i].DueDate != nil && atoms[j].DueDate != nil { if !atoms[i].DueDate.Equal(*atoms[j].DueDate) { return atoms[i].DueDate.Before(*atoms[j].DueDate) } } return atoms[i].Priority > atoms[j].Priority }) } // PartitionAtomsByTime separates atoms into current and future lists func PartitionAtomsByTime(atoms []models.Atom) (current, future []models.Atom) { for _, a := range atoms { if a.IsFuture { future = append(future, a) } else { current = append(current, a) } } return } // atomUrgencyTier returns an urgency tier for sorting: // 0 = overdue, 1 = today with specific time, 2 = today no time, 3 = future, 4 = no due date func atomUrgencyTier(a models.Atom) int { if a.DueDate == nil { return 4 } if a.IsOverdue { return 0 } if a.IsFuture { return 3 } if a.HasSetTime { return 1 } return 2 }