From c290113bd1a8af694b648bba4c801e00b049683a Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 24 Jan 2026 20:12:07 -1000 Subject: Add Shopping tab combining Trello and PlanToEat lists - New Shopping tab aggregates items from Trello Shopping board and PlanToEat - Items grouped by store, then by category (for PlanToEat) - Trello list names treated as store names - Replace PlanToEat meals API with web scraping (uses session cookie) - Add error logging for PlanToEat fetch operations - Recipe links now point to cooking view (/recipes/{id}/cook/{event_id}) Co-Authored-By: Claude Opus 4.5 --- internal/handlers/handlers.go | 123 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) (limited to 'internal/handlers/handlers.go') diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index a2d1f0b..4d38f72 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -145,6 +145,21 @@ func (h *Handler) HandleGetBoards(w http.ResponseWriter, r *http.Request) { JSONResponse(w, boards) } +// HandleGetShoppingList returns PlanToEat shopping list as JSON +func (h *Handler) HandleGetShoppingList(w http.ResponseWriter, r *http.Request) { + if h.planToEatClient == nil { + JSONError(w, http.StatusServiceUnavailable, "PlanToEat not configured", nil) + return + } + + items, err := h.planToEatClient.GetShoppingList(r.Context()) + if err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to get shopping list", err) + return + } + JSONResponse(w, items) +} + // HandleTasksTab renders the tasks tab content (Trello + Todoist + PlanToEat) func (h *Handler) HandleTasksTab(w http.ResponseWriter, r *http.Request) { data, err := h.aggregateData(r.Context(), false) @@ -186,6 +201,7 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models default: } if err := fn(); err != nil { + log.Printf("ERROR [%s]: %v", name, err) mu.Lock() data.Errors = append(data.Errors, name+": "+err.Error()) mu.Unlock() @@ -1151,6 +1167,113 @@ func (h *Handler) HandleTabMeals(w http.ResponseWriter, r *http.Request) { HTMLResponse(w, h.templates, "meals-tab", struct{ Meals []models.Meal }{meals}) } +// HandleTabShopping renders the Shopping tab (Trello Shopping board + PlanToEat) +func (h *Handler) HandleTabShopping(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + stores := h.aggregateShoppingLists(ctx) + HTMLResponse(w, h.templates, "shopping-tab", struct{ Stores []models.ShoppingStore }{stores}) +} + +// aggregateShoppingLists combines Trello and PlanToEat shopping items by store +func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingStore { + storeMap := make(map[string]map[string][]models.UnifiedShoppingItem) + + // 1. Fetch Trello "Shopping" board cards + boards, err := h.store.GetBoards() + if err != nil { + log.Printf("ERROR [Shopping/Trello]: %v", err) + } else { + for _, board := range boards { + if strings.EqualFold(board.Name, "Shopping") { + log.Printf("DEBUG [Shopping]: Found Shopping board with %d cards", len(board.Cards)) + for _, card := range board.Cards { + storeName := card.ListName + if storeName == "" { + storeName = "Uncategorized" + } + + if storeMap[storeName] == nil { + storeMap[storeName] = make(map[string][]models.UnifiedShoppingItem) + } + + item := models.UnifiedShoppingItem{ + ID: card.ID, + Name: card.Name, + Store: storeName, + Source: "trello", + } + storeMap[storeName][""] = append(storeMap[storeName][""], item) + } + } + } + } + + // 2. Fetch PlanToEat shopping list + if h.planToEatClient != nil { + items, err := h.planToEatClient.GetShoppingList(ctx) + if err != nil { + log.Printf("ERROR [Shopping/PlanToEat]: %v", err) + } else { + log.Printf("DEBUG [Shopping/PlanToEat]: Found %d items", len(items)) + for _, item := range items { + storeName := item.Store + if storeName == "" { + storeName = "PlanToEat" + } + + if storeMap[storeName] == nil { + storeMap[storeName] = make(map[string][]models.UnifiedShoppingItem) + } + + unified := models.UnifiedShoppingItem{ + ID: item.ID, + Name: item.Name, + Quantity: item.Quantity, + Category: item.Category, + Store: storeName, + Source: "plantoeat", + Checked: item.Checked, + } + storeMap[storeName][item.Category] = append(storeMap[storeName][item.Category], unified) + } + } + } else { + log.Printf("DEBUG [Shopping/PlanToEat]: Client not configured") + } + + // 3. Convert map to sorted slice + var stores []models.ShoppingStore + for storeName, categories := range storeMap { + store := models.ShoppingStore{Name: storeName} + + // Get sorted category names + var catNames []string + for catName := range categories { + catNames = append(catNames, catName) + } + sort.Strings(catNames) + + for _, catName := range catNames { + items := categories[catName] + sort.Slice(items, func(i, j int) bool { + return items[i].Name < items[j].Name + }) + store.Categories = append(store.Categories, models.ShoppingCategory{ + Name: catName, + Items: items, + }) + } + stores = append(stores, store) + } + + // Sort stores alphabetically + sort.Slice(stores, func(i, j int) bool { + return stores[i].Name < stores[j].Name + }) + + return stores +} + // isActionableList returns true if the list name indicates an actionable list func isActionableList(name string) bool { lower := strings.ToLower(name) -- cgit v1.2.3