From 5ddb419137b814481a208d1dd0d18ac36ed554ea Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 5 Feb 2026 10:41:43 -1000 Subject: Extract shopping handlers to dedicated file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved 8 shopping-related handlers from handlers.go to shopping.go: - HandleTabShopping - HandleShoppingQuickAdd - HandleShoppingToggle - HandleShoppingMode - HandleShoppingModeToggle - HandleShoppingModeComplete - HandleGetShoppingLists - aggregateShoppingLists handlers.go: 1633 → 1232 lines (24% reduction) shopping.go: 415 lines (new) All tests passing. No behavioral changes. Co-Authored-By: Claude Opus 4.5 --- internal/handlers/shopping.go | 415 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 internal/handlers/shopping.go (limited to 'internal/handlers/shopping.go') diff --git a/internal/handlers/shopping.go b/internal/handlers/shopping.go new file mode 100644 index 0000000..e8e80da --- /dev/null +++ b/internal/handlers/shopping.go @@ -0,0 +1,415 @@ +package handlers + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "sort" + "strings" + + "github.com/go-chi/chi/v5" + + "task-dashboard/internal/auth" + "task-dashboard/internal/models" +) + +// HandleTabShopping renders the Shopping tab (Trello Shopping board + PlanToEat + User items) +func (h *Handler) HandleTabShopping(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + stores := h.aggregateShoppingLists(ctx) + grouped := r.URL.Query().Get("grouped") != "false" // Default to grouped + HTMLResponse(w, h.renderer, "shopping-tab", struct { + Stores []models.ShoppingStore + Grouped bool + }{stores, grouped}) +} + +// HandleShoppingQuickAdd adds a user shopping item +func (h *Handler) HandleShoppingQuickAdd(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) + return + } + + name := strings.TrimSpace(r.FormValue("name")) + storeName := strings.TrimSpace(r.FormValue("store")) + mode := r.FormValue("mode") // "shopping-mode" if from shopping mode page + + if name == "" { + JSONError(w, http.StatusBadRequest, "Name is required", nil) + return + } + if storeName == "" { + JSONError(w, http.StatusBadRequest, "Store is required", nil) + return + } + + if err := h.store.SaveUserShoppingItem(name, storeName); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to save item", err) + return + } + + ctx := r.Context() + allStores := h.aggregateShoppingLists(ctx) + + // If called from shopping mode, return just the items for that store + if mode == "shopping-mode" { + items := models.FlattenItemsForStore(allStores, storeName) + HTMLResponse(w, h.renderer, "shopping-mode-items", struct { + StoreName string + Items []models.UnifiedShoppingItem + }{storeName, items}) + return + } + + // Return just the items content (not the entire tab) to preserve scroll position + grouped := r.FormValue("grouped") != "false" + HTMLResponse(w, h.renderer, "shopping-items-content", struct { + Stores []models.ShoppingStore + Grouped bool + }{allStores, grouped}) +} + +// HandleShoppingToggle toggles a shopping item's checked state +func (h *Handler) HandleShoppingToggle(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) + return + } + + id := r.FormValue("id") + source := r.FormValue("source") + checked := r.FormValue("checked") == "true" + + switch source { + case "user": + var userID int64 + if _, err := fmt.Sscanf(id, "user-%d", &userID); err != nil { + JSONError(w, http.StatusBadRequest, "Invalid user item ID", err) + return + } + if err := h.store.ToggleUserShoppingItem(userID, checked); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err) + return + } + case "trello", "plantoeat": + // Store checked state locally for external sources + if err := h.store.SetShoppingItemChecked(source, id, checked); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err) + return + } + default: + JSONError(w, http.StatusBadRequest, "Unknown source", nil) + return + } + + // Return refreshed shopping tab + stores := h.aggregateShoppingLists(r.Context()) + HTMLResponse(w, h.renderer, "shopping-tab", struct { + Stores []models.ShoppingStore + Grouped bool + }{stores, true}) +} + +// HandleShoppingMode renders the focused shopping mode for a single store +func (h *Handler) HandleShoppingMode(w http.ResponseWriter, r *http.Request) { + storeName := chi.URLParam(r, "store") + if storeName == "" { + http.Redirect(w, r, "/?tab=shopping", http.StatusFound) + return + } + + // URL decode the store name + storeName, _ = url.QueryUnescape(storeName) + + ctx := r.Context() + allStores := h.aggregateShoppingLists(ctx) + + data := struct { + StoreName string + Items []models.UnifiedShoppingItem + StoreNames []string + CSRFToken string + }{ + StoreName: storeName, + Items: models.FlattenItemsForStore(allStores, storeName), + StoreNames: models.StoreNames(allStores), + CSRFToken: auth.GetCSRFTokenFromContext(ctx), + } + + HTMLResponse(w, h.renderer, "shopping-mode.html", data) +} + +// HandleShoppingModeToggle toggles an item in shopping mode and returns updated list +func (h *Handler) HandleShoppingModeToggle(w http.ResponseWriter, r *http.Request) { + storeName := chi.URLParam(r, "store") + if err := r.ParseForm(); err != nil { + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) + return + } + + id := r.FormValue("id") + source := r.FormValue("source") + checked := r.FormValue("checked") == "true" + + // Toggle the item + switch source { + case "user": + var userID int64 + if _, err := fmt.Sscanf(id, "user-%d", &userID); err != nil { + JSONError(w, http.StatusBadRequest, "Invalid user item ID", err) + return + } + if err := h.store.ToggleUserShoppingItem(userID, checked); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err) + return + } + case "trello", "plantoeat": + if err := h.store.SetShoppingItemChecked(source, id, checked); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err) + return + } + } + + // URL decode the store name + storeName, _ = url.QueryUnescape(storeName) + + // Return updated item list partial + allStores := h.aggregateShoppingLists(r.Context()) + HTMLResponse(w, h.renderer, "shopping-mode-items", struct { + StoreName string + Items []models.UnifiedShoppingItem + }{storeName, models.FlattenItemsForStore(allStores, storeName)}) +} + +// HandleShoppingModeComplete removes an item from the shopping list +func (h *Handler) HandleShoppingModeComplete(w http.ResponseWriter, r *http.Request) { + storeName := chi.URLParam(r, "store") + if err := r.ParseForm(); err != nil { + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) + return + } + + id := r.FormValue("id") + source := r.FormValue("source") + + // Complete (remove) the item + switch source { + case "user": + var userID int64 + if _, err := fmt.Sscanf(id, "user-%d", &userID); err != nil { + JSONError(w, http.StatusBadRequest, "Invalid user item ID", err) + return + } + if err := h.store.DeleteUserShoppingItem(userID); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to delete item", err) + return + } + case "trello", "plantoeat": + // Mark as checked (will be filtered out of view) + if err := h.store.SetShoppingItemChecked(source, id, true); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to complete item", err) + return + } + } + + // URL decode the store name + storeName, _ = url.QueryUnescape(storeName) + + // Return updated item list partial + ctx := r.Context() + allStores := h.aggregateShoppingLists(ctx) + + var items []models.UnifiedShoppingItem + for _, store := range allStores { + if strings.EqualFold(store.Name, storeName) { + for _, cat := range store.Categories { + items = append(items, cat.Items...) + } + } + } + + // Sort alphabetically (checked items already filtered in template) + sort.Slice(items, func(i, j int) bool { + return items[i].Name < items[j].Name + }) + + HTMLResponse(w, h.renderer, "shopping-mode-items", struct { + StoreName string + Items []models.UnifiedShoppingItem + }{storeName, items}) +} + +// HandleGetShoppingLists returns the lists from the Shopping board for quick-add +func (h *Handler) HandleGetShoppingLists(w http.ResponseWriter, r *http.Request) { + boards, err := h.store.GetBoards() + if err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to get boards", err) + return + } + + var shoppingBoardID string + for _, b := range boards { + if strings.EqualFold(b.Name, "Shopping") { + shoppingBoardID = b.ID + break + } + } + + if shoppingBoardID == "" { + JSONError(w, http.StatusNotFound, "Shopping board not found", nil) + return + } + + lists, err := h.trelloClient.GetLists(r.Context(), shoppingBoardID) + if err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to get lists", err) + return + } + + w.Header().Set("Content-Type", "text/html") + for _, list := range lists { + _, _ = fmt.Fprintf(w, ``, + template.HTMLEscapeString(list.ID), + template.HTMLEscapeString(list.Name)) + } +} + +// aggregateShoppingLists combines Trello, PlanToEat, and user shopping items by store +func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingStore { + storeMap := make(map[string]map[string][]models.UnifiedShoppingItem) + + // Fetch locally stored checked states for external sources + trelloChecks, _ := h.store.GetShoppingItemChecks("trello") + plantoeatChecks, _ := h.store.GetShoppingItemChecks("plantoeat") + + // 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", + Checked: trelloChecks[card.ID], + } + 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) + } + + // Use locally stored check state if available, otherwise use scraped state + checked := item.Checked + if localChecked, ok := plantoeatChecks[item.ID]; ok { + checked = localChecked + } + unified := models.UnifiedShoppingItem{ + ID: item.ID, + Name: item.Name, + Quantity: item.Quantity, + Category: item.Category, + Store: storeName, + Source: "plantoeat", + Checked: checked, + } + storeMap[storeName][item.Category] = append(storeMap[storeName][item.Category], unified) + } + } + } else { + log.Printf("DEBUG [Shopping/PlanToEat]: Client not configured") + } + + // 3. Fetch user-added shopping items + userItems, err := h.store.GetUserShoppingItems() + if err != nil { + log.Printf("ERROR [Shopping/User]: %v", err) + } else { + for _, item := range userItems { + storeName := item.Store + if storeName == "" { + continue // Skip items without a store + } + + if storeMap[storeName] == nil { + storeMap[storeName] = make(map[string][]models.UnifiedShoppingItem) + } + + unified := models.UnifiedShoppingItem{ + ID: fmt.Sprintf("user-%d", item.ID), + Name: item.Name, + Store: storeName, + Source: "user", + Checked: item.Checked, + } + storeMap[storeName][""] = append(storeMap[storeName][""], unified) + } + } + + // 4. 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 +} -- cgit v1.2.3