summaryrefslogtreecommitdiff
path: root/internal/handlers/shopping.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/handlers/shopping.go')
-rw-r--r--internal/handlers/shopping.go415
1 files changed, 415 insertions, 0 deletions
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, `<option value="%s">%s</option>`,
+ 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
+}