package handlers import ( "errors" "fmt" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "task-dashboard/internal/models" "task-dashboard/internal/store" ) // shoppingItemEditView is everything the edit sheet needs. // // Editable is the crux: only user-added items can be changed. PlanToEat items // are scraped through a read-only client, so the sheet says so and offers // removal rather than presenting fields that would silently not save. type shoppingItemEditView struct { ID string UserID int64 Name string Store string Section string Source string Editable bool ReadOnlyWhy string Stores []storeOptionView } type storeOptionView struct { Name string Selected bool Sections []sectionOptionView } type sectionOptionView struct { Name string Selected bool } // parseUserItemID pulls the numeric id out of a "user-123" composite id. func parseUserItemID(id string) (int64, bool) { if !strings.HasPrefix(id, "user-") { return 0, false } n, err := strconv.ParseInt(strings.TrimPrefix(id, "user-"), 10, 64) if err != nil { return 0, false } return n, true } // HandleShoppingItemEdit renders the edit sheet for one item. func (h *Handler) HandleShoppingItemEdit(w http.ResponseWriter, r *http.Request) { itemID := chi.URLParam(r, "id") source := r.URL.Query().Get("source") view := shoppingItemEditView{ID: itemID, Source: source} if userID, ok := parseUserItemID(itemID); ok { item, err := h.store.GetUserShoppingItem(userID) if err != nil { if errors.Is(err, store.ErrItemNotFound) { JSONError(w, http.StatusNotFound, "Item not found", err) return } JSONError(w, http.StatusInternalServerError, "Failed to load item", err) return } view.UserID = userID view.Name = item.Name view.Store = item.Store view.Section = item.Section view.Source = "user" view.Editable = true } else { // Everything else comes from PlanToEat, whose client is read-only. view.Name = r.URL.Query().Get("name") view.Store = r.URL.Query().Get("store") view.Editable = false view.ReadOnlyWhy = "This item is synced from PlanToEat. Edit it there, or remove it from this list." } view.Stores = h.storeOptions(view.Store, view.Section) HTMLResponse(w, h.renderer, "shopping-item-edit", view) } // storeOptions builds the store/section picker, marking the current selection. func (h *Handler) storeOptions(currentStore, currentSection string) []storeOptionView { stores, err := h.store.GetShoppingStores() if err != nil { return nil } seen := map[string]bool{} out := make([]storeOptionView, 0, len(stores)) for _, st := range stores { seen[st.Name] = true opt := storeOptionView{Name: st.Name, Selected: st.Name == currentStore} for _, sec := range st.Sections { opt.Sections = append(opt.Sections, sectionOptionView{ Name: sec.Name, Selected: st.Name == currentStore && sec.Name == currentSection, }) } out = append(out, opt) } // An item can sit in a store that predates the registry (imported, or // named by PlanToEat). Offer it so the current value is selectable rather // than silently reset on save. if currentStore != "" && !seen[currentStore] { out = append(out, storeOptionView{Name: currentStore, Selected: true}) } return out } // HandleShoppingItemUpdate saves an edit. func (h *Handler) HandleShoppingItemUpdate(w http.ResponseWriter, r *http.Request) { itemID := chi.URLParam(r, "id") userID, ok := parseUserItemID(itemID) if !ok { JSONError(w, http.StatusBadRequest, "Only your own items can be edited", nil) return } if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } name := r.FormValue("name") storeName := r.FormValue("store") section := r.FormValue("section") if err := h.store.UpdateUserShoppingItem(userID, name, storeName, section); err != nil { switch { case errors.Is(err, store.ErrItemNotFound): JSONError(w, http.StatusNotFound, "Item not found", err) default: JSONError(w, http.StatusBadRequest, err.Error(), err) } return } h.respondWithShoppingList(w, r) } // HandleShoppingItemDelete removes an item. // // A user item is deleted outright. A PlanToEat item cannot be deleted at // source, so it is marked checked, which filters it out of the list — the same // mechanism shopping mode already uses to complete one. func (h *Handler) HandleShoppingItemDelete(w http.ResponseWriter, r *http.Request) { itemID := chi.URLParam(r, "id") if userID, ok := parseUserItemID(itemID); ok { if err := h.store.DeleteUserShoppingItem(userID); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to delete item", err) return } } else { source := r.URL.Query().Get("source") if source == "" { source = "plantoeat" } if err := h.store.SetShoppingItemChecked(source, itemID, true); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to remove item", err) return } } h.respondWithShoppingList(w, r) } // HandleShoppingStoreCreate registers a new store. func (h *Handler) HandleShoppingStoreCreate(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } if err := h.store.AddShoppingStore(r.FormValue("name")); err != nil { JSONError(w, http.StatusBadRequest, err.Error(), err) return } h.respondWithShoppingList(w, r) } // HandleShoppingSectionCreate adds a section to a store, addressed by name so // the caller does not need to know the store's id. func (h *Handler) HandleShoppingSectionCreate(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } storeName := strings.TrimSpace(r.FormValue("store")) sectionName := strings.TrimSpace(r.FormValue("name")) stores, err := h.store.GetShoppingStores() if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to load stores", err) return } for _, st := range stores { if strings.EqualFold(st.Name, storeName) { if err := h.store.AddShoppingSection(st.ID, sectionName); err != nil { JSONError(w, http.StatusBadRequest, err.Error(), err) return } h.respondWithShoppingList(w, r) return } } JSONError(w, http.StatusNotFound, fmt.Sprintf("No store named %q", storeName), nil) } // respondWithShoppingList re-renders the items list, which is what every edit // action swaps into the page. func (h *Handler) respondWithShoppingList(w http.ResponseWriter, r *http.Request) { allStores := h.aggregateShoppingLists(r.Context()) grouped := r.FormValue("grouped") != "false" if v := r.URL.Query().Get("grouped"); v != "" { grouped = v != "false" } HTMLResponse(w, h.renderer, "shopping-items-content", struct { Stores []models.ShoppingStore Grouped bool }{allStores, grouped}) }