diff options
Diffstat (limited to 'internal/handlers/shopping_edit.go')
| -rw-r--r-- | internal/handlers/shopping_edit.go | 232 |
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}) +} |
