summaryrefslogtreecommitdiff
path: root/internal/handlers/shopping_edit.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/handlers/shopping_edit.go')
-rw-r--r--internal/handlers/shopping_edit.go232
1 files changed, 232 insertions, 0 deletions
diff --git a/internal/handlers/shopping_edit.go b/internal/handlers/shopping_edit.go
new file mode 100644
index 0000000..2c0c36e
--- /dev/null
+++ b/internal/handlers/shopping_edit.go
@@ -0,0 +1,232 @@
+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})
+}