summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/handlers/handlers.go454
-rw-r--r--internal/handlers/shopping.go415
2 files changed, 415 insertions, 454 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index bd05cd0..650eeaa 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -6,15 +6,12 @@ import (
"html/template"
"log"
"net/http"
- "net/url"
"path/filepath"
"sort"
"strings"
"sync"
"time"
- "github.com/go-chi/chi/v5"
-
"task-dashboard/internal/api"
"task-dashboard/internal/auth"
"task-dashboard/internal/config"
@@ -926,41 +923,6 @@ func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) {
HTMLString(w, html)
}
-// 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))
- }
-}
-
// HandleUpdateTask updates a task description
func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
@@ -1231,422 +1193,6 @@ func mealTypeOrder(mealType string) int {
}
}
-// 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" {
- 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: unchecked first, then checked
- sort.Slice(items, func(i, j int) bool {
- if items[i].Checked != items[j].Checked {
- return !items[i].Checked
- }
- return items[i].Name < items[j].Name
- })
- HTMLResponse(w, h.renderer, "shopping-mode-items", struct {
- StoreName string
- Items []models.UnifiedShoppingItem
- }{storeName, items})
- return
- }
-
- // Return refreshed shopping tab
- HTMLResponse(w, h.renderer, "shopping-tab", struct {
- Stores []models.ShoppingStore
- Grouped bool
- }{allStores, true})
-}
-
-// 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)
-
- // Find the requested store
- var items []models.UnifiedShoppingItem
- var storeNames []string
- for _, store := range allStores {
- storeNames = append(storeNames, store.Name)
- if strings.EqualFold(store.Name, storeName) {
- // Flatten categories into single item list
- for _, cat := range store.Categories {
- items = append(items, cat.Items...)
- }
- }
- }
-
- // Sort: unchecked first, then checked (both alphabetically within their group)
- sort.Slice(items, func(i, j int) bool {
- if items[i].Checked != items[j].Checked {
- return !items[i].Checked // unchecked items first
- }
- return items[i].Name < items[j].Name
- })
-
- data := struct {
- StoreName string
- Items []models.UnifiedShoppingItem
- StoreNames []string
- CSRFToken string
- }{
- StoreName: storeName,
- Items: items,
- StoreNames: storeNames,
- 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
- 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: unchecked first, then checked
- sort.Slice(items, func(i, j int) bool {
- if items[i].Checked != items[j].Checked {
- return !items[i].Checked
- }
- return items[i].Name < items[j].Name
- })
-
- HTMLResponse(w, h.renderer, "shopping-mode-items", struct {
- StoreName string
- Items []models.UnifiedShoppingItem
- }{storeName, items})
-}
-
-// 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})
-}
-
-// 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
-}
-
// HandleTabConditions renders the Conditions tab with live feeds
func (h *Handler) HandleTabConditions(w http.ResponseWriter, r *http.Request) {
HTMLResponse(w, h.renderer, "conditions-tab", nil)
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
+}