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 }