diff options
| -rw-r--r-- | internal/api/todoist.go | 54 | ||||
| -rw-r--r-- | internal/api/todoist_test.go | 51 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 68 | ||||
| -rw-r--r-- | internal/handlers/meals.go | 51 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic.go | 44 |
5 files changed, 145 insertions, 123 deletions
diff --git a/internal/api/todoist.go b/internal/api/todoist.go index 2745e3e..be699ce 100644 --- a/internal/api/todoist.go +++ b/internal/api/todoist.go @@ -166,34 +166,42 @@ func (c *TodoistClient) Sync(ctx context.Context, syncToken string) (*TodoistSyn return &syncResp, nil } +// ConvertSyncItemToTask converts a single sync item to a Task model. +// Returns the task and true if the item is active, or a zero Task and false if it should be skipped. +func ConvertSyncItemToTask(item SyncItemResponse, projectMap map[string]string) (models.Task, bool) { + if item.IsCompleted || item.IsDeleted { + return models.Task{}, false + } + + task := models.Task{ + ID: item.ID, + Content: item.Content, + Description: item.Description, + ProjectID: item.ProjectID, + ProjectName: projectMap[item.ProjectID], + Priority: item.Priority, + Completed: false, + Labels: item.Labels, + URL: fmt.Sprintf("https://todoist.com/app/task/%s", item.ID), + } + + if item.AddedAt != "" { + if createdAt, err := time.Parse(time.RFC3339, item.AddedAt); err == nil { + task.CreatedAt = createdAt + } + } + + task.DueDate = parseDueDate(item.Due) + return task, true +} + // ConvertSyncItemsToTasks converts sync API items to Task models func ConvertSyncItemsToTasks(items []SyncItemResponse, projectMap map[string]string) []models.Task { tasks := make([]models.Task, 0, len(items)) for _, item := range items { - if item.IsCompleted || item.IsDeleted { - continue - } - - task := models.Task{ - ID: item.ID, - Content: item.Content, - Description: item.Description, - ProjectID: item.ProjectID, - ProjectName: projectMap[item.ProjectID], - Priority: item.Priority, - Completed: false, - Labels: item.Labels, - URL: fmt.Sprintf("https://todoist.com/app/task/%s", item.ID), + if task, ok := ConvertSyncItemToTask(item, projectMap); ok { + tasks = append(tasks, task) } - - if item.AddedAt != "" { - if createdAt, err := time.Parse(time.RFC3339, item.AddedAt); err == nil { - task.CreatedAt = createdAt - } - } - - task.DueDate = parseDueDate(item.Due) - tasks = append(tasks, task) } return tasks } diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go index 159be61..2204469 100644 --- a/internal/api/todoist_test.go +++ b/internal/api/todoist_test.go @@ -421,6 +421,57 @@ func TestConvertSyncItemsToTasks(t *testing.T) { } } +func TestConvertSyncItemToTask(t *testing.T) { + projects := map[string]string{"proj-1": "Project 1"} + + t.Run("active item returns task and true", func(t *testing.T) { + item := SyncItemResponse{ + ID: "item-1", + Content: "Active Task", + Description: "desc", + ProjectID: "proj-1", + Priority: 2, + Labels: []string{"work"}, + AddedAt: "2026-01-01T00:00:00Z", + } + task, ok := ConvertSyncItemToTask(item, projects) + if !ok { + t.Fatal("expected ok=true for active item") + } + if task.ID != "item-1" { + t.Errorf("expected ID 'item-1', got '%s'", task.ID) + } + if task.Content != "Active Task" { + t.Errorf("expected Content 'Active Task', got '%s'", task.Content) + } + if task.ProjectName != "Project 1" { + t.Errorf("expected ProjectName 'Project 1', got '%s'", task.ProjectName) + } + if task.Completed { + t.Error("expected Completed=false") + } + if task.URL != "https://todoist.com/app/task/item-1" { + t.Errorf("unexpected URL: %s", task.URL) + } + }) + + t.Run("completed item returns false", func(t *testing.T) { + item := SyncItemResponse{ID: "item-2", Content: "Done", ProjectID: "proj-1", IsCompleted: true} + _, ok := ConvertSyncItemToTask(item, projects) + if ok { + t.Error("expected ok=false for completed item") + } + }) + + t.Run("deleted item returns false", func(t *testing.T) { + item := SyncItemResponse{ID: "item-3", Content: "Gone", ProjectID: "proj-1", IsDeleted: true} + _, ok := ConvertSyncItemToTask(item, projects) + if ok { + t.Error("expected ok=false for deleted item") + } + }) +} + func TestBuildProjectMapFromSync(t *testing.T) { projects := []SyncProjectResponse{ {ID: "proj-1", Name: "Project 1"}, diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 39e67c9..ce2c57e 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -381,7 +381,7 @@ func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.T deletedIDs = append(deletedIDs, item.ID) } else { // Upsert active task - task := h.convertSyncItemToTask(item, projectMap) + task, _ := api.ConvertSyncItemToTask(item, projectMap) if err := h.store.UpsertTask(task); err != nil { log.Printf("Failed to upsert task %s: %v", item.ID, err) } @@ -408,27 +408,6 @@ func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.T return h.store.GetTasks() } -// convertSyncItemToTask converts a sync item to a Task model -func (h *Handler) convertSyncItemToTask(item api.SyncItemResponse, projectMap map[string]string) models.Task { - // Use the ConvertSyncItemsToTasks helper for single item conversion - items := api.ConvertSyncItemsToTasks([]api.SyncItemResponse{item}, projectMap) - if len(items) > 0 { - return items[0] - } - // Fallback for completed/deleted items (shouldn't happen in practice) - return models.Task{ - ID: item.ID, - Content: item.Content, - Description: item.Description, - ProjectID: item.ProjectID, - ProjectName: projectMap[item.ProjectID], - Priority: item.Priority, - Completed: item.IsCompleted, - Labels: item.Labels, - URL: fmt.Sprintf("https://todoist.com/app/task/%s", item.ID), - } -} - // fetchMeals fetches meals from cache or API func (h *Handler) fetchMeals(ctx context.Context, forceRefresh bool) ([]models.Meal, error) { startDate := config.Now() @@ -1104,10 +1083,12 @@ func (h *Handler) HandleTabPlanning(w http.ResponseWriter, r *http.Request) { // CombinedMeal represents multiple meals combined for same date+mealType type CombinedMeal struct { + ID string RecipeNames []string Date time.Time MealType string - RecipeURL string // URL of first meal + RecipeURL string // URL of first meal + Meals []models.Meal // original meal records } // HandleTabMeals renders the Meals tab (PlanToEat) @@ -1121,46 +1102,7 @@ func (h *Handler) HandleTabMeals(w http.ResponseWriter, r *http.Request) { return } - // Group meals by date+mealType - 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) - } - - // Convert to combined meals - var combined []CombinedMeal - for _, group := range mealGroups { - if len(group) == 0 { - continue - } - cm := CombinedMeal{ - Date: group[0].Date, - MealType: group[0].MealType, - RecipeURL: group[0].RecipeURL, - } - for _, m := range group { - cm.RecipeNames = append(cm.RecipeNames, m.RecipeName) - } - combined = append(combined, cm) - } - - // Sort by date then meal type order - sort.Slice(combined, func(i, j int) bool { - if !combined[i].Date.Equal(combined[j].Date) { - return combined[i].Date.Before(combined[j].Date) - } - return mealTypeOrder(combined[i].MealType) < mealTypeOrder(combined[j].MealType) - }) - - HTMLResponse(w, h.renderer, "meals-tab", struct{ Meals []CombinedMeal }{combined}) + HTMLResponse(w, h.renderer, "meals-tab", struct{ Meals []CombinedMeal }{groupMeals(meals)}) } // mealTypeOrder returns sort order for meal types diff --git a/internal/handlers/meals.go b/internal/handlers/meals.go new file mode 100644 index 0000000..c4c327c --- /dev/null +++ b/internal/handlers/meals.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "sort" + + "task-dashboard/internal/models" +) + +// groupMeals groups meals by date+mealType, combining recipe names into CombinedMeal entries. +// Results are sorted by date then meal type (breakfast → lunch → dinner). +func groupMeals(meals []models.Meal) []CombinedMeal { + 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) + } + + var combined []CombinedMeal + for _, group := range mealGroups { + if len(group) == 0 { + continue + } + cm := CombinedMeal{ + ID: group[0].ID, + Date: group[0].Date, + MealType: group[0].MealType, + RecipeURL: group[0].RecipeURL, + Meals: group, + } + for _, m := range group { + cm.RecipeNames = append(cm.RecipeNames, m.RecipeName) + } + combined = append(combined, cm) + } + + sort.Slice(combined, func(i, j int) bool { + if !combined[i].Date.Equal(combined[j].Date) { + return combined[i].Date.Before(combined[j].Date) + } + return mealTypeOrder(combined[i].MealType) < mealTypeOrder(combined[j].MealType) + }) + + return combined +} diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go index 4334f6d..adfa406 100644 --- a/internal/handlers/timeline_logic.go +++ b/internal/handlers/timeline_logic.go @@ -48,32 +48,9 @@ func BuildTimeline(ctx context.Context, s *store.Store, tasksClient api.GoogleTa 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 { + 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": @@ -84,20 +61,13 @@ func BuildTimeline(ctx context.Context, s *store.Store, tasksClient api.GoogleTa 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, + ID: cm.ID, Type: models.TimelineItemTypeMeal, - Title: combinedTitle, + Title: strings.Join(cm.RecipeNames, " + "), Time: mealTime, - URL: firstMeal.RecipeURL, // Use first meal's URL - OriginalItem: group, // Store all meals + URL: cm.RecipeURL, + OriginalItem: cm.Meals, IsCompleted: false, Source: "plantoeat", } |
