summaryrefslogtreecommitdiff
path: root/internal/handlers
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-24 20:12:07 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-24 20:12:07 -1000
commitc290113bd1a8af694b648bba4c801e00b049683a (patch)
tree75d066a74d0f3e596d3fbe5bd89f8e2d449ca011 /internal/handlers
parentb69d2d5fc8779f43b1ac789605318488efc91361 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'internal/handlers')
-rw-r--r--internal/handlers/handlers.go123
1 files changed, 123 insertions, 0 deletions
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)