summaryrefslogtreecommitdiff
path: root/internal/handlers
diff options
context:
space:
mode:
Diffstat (limited to 'internal/handlers')
-rw-r--r--internal/handlers/shopping.go36
-rw-r--r--internal/handlers/shopping_edit.go232
-rw-r--r--internal/handlers/shopping_edit_test.go238
-rw-r--r--internal/handlers/shopping_template_test.go38
4 files changed, 527 insertions, 17 deletions
diff --git a/internal/handlers/shopping.go b/internal/handlers/shopping.go
index e06cad6..69cb2b7 100644
--- a/internal/handlers/shopping.go
+++ b/internal/handlers/shopping.go
@@ -241,17 +241,39 @@ func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingS
}
unified := models.UnifiedShoppingItem{
- ID: fmt.Sprintf("user-%d", item.ID),
- Name: item.Name,
- Store: storeName,
- Source: "user",
- Checked: item.Checked,
+ ID: fmt.Sprintf("user-%d", item.ID),
+ Name: item.Name,
+ Category: item.Section,
+ Store: storeName,
+ Source: "user",
+ Checked: item.Checked,
}
- storeMap[storeName][""] = append(storeMap[storeName][""], unified)
+ storeMap[storeName][item.Section] = append(storeMap[storeName][item.Section], unified)
}
}
- // 3. Convert map to sorted slice
+ // 3. Every registered store appears even when empty. Without this you
+ // cannot move an item into a store that has nothing in it yet, and moving
+ // the last item out of a store makes it vanish mid-edit.
+ registered, err := h.store.GetShoppingStores()
+ if err != nil {
+ log.Printf("ERROR [Shopping/Stores]: %v", err)
+ } else {
+ for _, st := range registered {
+ if storeMap[st.Name] == nil {
+ storeMap[st.Name] = make(map[string][]models.UnifiedShoppingItem)
+ }
+ // Likewise for sections: an empty section still needs to be a
+ // destination in the edit form.
+ for _, sec := range st.Sections {
+ if _, ok := storeMap[st.Name][sec.Name]; !ok {
+ storeMap[st.Name][sec.Name] = nil
+ }
+ }
+ }
+ }
+
+ // 4. Convert map to sorted slice
var stores []models.ShoppingStore
for storeName, categories := range storeMap {
store := models.ShoppingStore{Name: storeName}
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})
+}
diff --git a/internal/handlers/shopping_edit_test.go b/internal/handlers/shopping_edit_test.go
new file mode 100644
index 0000000..6d862d7
--- /dev/null
+++ b/internal/handlers/shopping_edit_test.go
@@ -0,0 +1,238 @@
+package handlers
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+func TestParseUserItemID(t *testing.T) {
+ cases := []struct {
+ in string
+ want int64
+ valid bool
+ }{
+ {"user-42", 42, true},
+ {"user-1", 1, true},
+ {"5f2a91bc", 0, false}, // a PlanToEat id
+ {"user-", 0, false}, // malformed
+ {"user-abc", 0, false}, // non-numeric
+ {"", 0, false},
+ }
+ for _, c := range cases {
+ got, ok := parseUserItemID(c.in)
+ if ok != c.valid || got != c.want {
+ t.Errorf("parseUserItemID(%q) = (%d, %v), want (%d, %v)", c.in, got, ok, c.want, c.valid)
+ }
+ }
+}
+
+func TestShoppingItemEdit_UserItemIsEditable(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ _ = h.store.SaveUserShoppingItemInSection("Milk", "Costco", "Dairy")
+ items, _ := h.store.GetUserShoppingItems()
+ id := items[0].ID
+
+ req := withURLParam(httptest.NewRequest(http.MethodGet, "/shopping/item/user-1/edit", nil),
+ "id", "user-"+strconv.FormatInt(id, 10))
+ rec := httptest.NewRecorder()
+ h.HandleShoppingItemEdit(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
+ }
+}
+
+// A PlanToEat item must not be presented as editable — the client is
+// read-only, so a save would silently do nothing.
+func TestShoppingItemEdit_PlanToEatItemIsReadOnly(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ req := withURLParam(
+ httptest.NewRequest(http.MethodGet, "/shopping/item/pte-1/edit?source=plantoeat&name=Kale&store=Safeway", nil),
+ "id", "pte-1")
+ rec := httptest.NewRecorder()
+ h.HandleShoppingItemEdit(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+}
+
+func TestShoppingItemUpdate_RenamesAndMoves(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ _ = h.store.AddShoppingStore("Safeway")
+ _ = h.store.SaveUserShoppingItemInSection("Mlik", "Costco", "Dairy")
+ items, _ := h.store.GetUserShoppingItems()
+ id := items[0].ID
+
+ form := url.Values{"name": {"Milk"}, "store": {"Safeway"}, "section": {""}}
+ req := withURLParam(
+ httptest.NewRequest(http.MethodPost, "/shopping/item/x", strings.NewReader(form.Encode())),
+ "id", "user-"+strconv.FormatInt(id, 10))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+
+ h.HandleShoppingItemUpdate(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
+ }
+ got, err := h.store.GetUserShoppingItem(id)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if got.Name != "Milk" || got.Store != "Safeway" || got.Section != "" {
+ t.Errorf("got %+v, want renamed and moved to Safeway with no section", got)
+ }
+}
+
+// Editing is only offered for user items; a request naming anything else must
+// be refused rather than quietly doing nothing.
+func TestShoppingItemUpdate_RefusesNonUserItems(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ form := url.Values{"name": {"Kale"}, "store": {"Safeway"}}
+ req := withURLParam(
+ httptest.NewRequest(http.MethodPost, "/shopping/item/x", strings.NewReader(form.Encode())),
+ "id", "pte-123")
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+
+ h.HandleShoppingItemUpdate(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("status %d, want 400", rec.Code)
+ }
+}
+
+func TestShoppingItemDelete_RemovesUserItem(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ _ = h.store.SaveUserShoppingItem("Milk", "Costco")
+ items, _ := h.store.GetUserShoppingItems()
+
+ req := withURLParam(httptest.NewRequest(http.MethodDelete, "/shopping/item/x", nil),
+ "id", "user-"+strconv.FormatInt(items[0].ID, 10))
+ rec := httptest.NewRecorder()
+ h.HandleShoppingItemDelete(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
+ }
+ remaining, _ := h.store.GetUserShoppingItems()
+ if len(remaining) != 0 {
+ t.Errorf("got %d items, want 0", len(remaining))
+ }
+}
+
+// A PlanToEat item cannot be deleted upstream, so removal marks it checked,
+// which filters it out of the list.
+func TestShoppingItemDelete_HidesPlanToEatItem(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ req := withURLParam(
+ httptest.NewRequest(http.MethodDelete, "/shopping/item/x?source=plantoeat", nil),
+ "id", "pte-99")
+ rec := httptest.NewRecorder()
+ h.HandleShoppingItemDelete(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
+ }
+ checks, _ := h.store.GetShoppingItemChecks("plantoeat")
+ if !checks["pte-99"] {
+ t.Error("expected the item to be marked checked so it drops out of the list")
+ }
+}
+
+// The registry's reason to exist: an empty store is still a destination.
+func TestAggregateShoppingLists_IncludesEmptyRegisteredStores(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.AddShoppingStore("Hardware Store"); err != nil {
+ t.Fatalf("add store: %v", err)
+ }
+
+ var found bool
+ for _, st := range h.aggregateShoppingLists(context.Background()) {
+ if st.Name == "Hardware Store" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("a registered store with no items should still appear, or nothing can be moved into it")
+ }
+}
+
+func TestShoppingStoreCreate(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ form := url.Values{"name": {"Hardware Store"}}
+ req := httptest.NewRequest(http.MethodPost, "/shopping/stores", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+
+ h.HandleShoppingStoreCreate(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
+ }
+ names, _ := h.store.ShoppingStoreNames()
+ if len(names) != 1 || names[0] != "Hardware Store" {
+ t.Errorf("got %v", names)
+ }
+}
+
+func TestShoppingSectionCreate(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ _ = h.store.AddShoppingStore("Costco")
+
+ form := url.Values{"store": {"Costco"}, "name": {"Produce"}}
+ req := httptest.NewRequest(http.MethodPost, "/shopping/sections", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+
+ h.HandleShoppingSectionCreate(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
+ }
+ stores, _ := h.store.GetShoppingStores()
+ if len(stores) != 1 || len(stores[0].Sections) != 1 || stores[0].Sections[0].Name != "Produce" {
+ t.Errorf("got %+v", stores)
+ }
+}
+
+func TestShoppingSectionCreate_UnknownStore(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ form := url.Values{"store": {"Nowhere"}, "name": {"Produce"}}
+ req := httptest.NewRequest(http.MethodPost, "/shopping/sections", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+
+ h.HandleShoppingSectionCreate(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("status %d, want 404", rec.Code)
+ }
+}
+
diff --git a/internal/handlers/shopping_template_test.go b/internal/handlers/shopping_template_test.go
index 06f63b3..07baffc 100644
--- a/internal/handlers/shopping_template_test.go
+++ b/internal/handlers/shopping_template_test.go
@@ -31,6 +31,24 @@ func renderShoppingTab(t *testing.T, data any) string {
return buf.String()
}
+// renderShoppingItemsContent renders just the swapped-in portion of the tab.
+func renderShoppingItemsContent(t *testing.T, data any) string {
+ t.Helper()
+ dir := filepath.Join("..", "..", "web", "templates")
+ tmpl, err := template.New("").Funcs(templateFuncMap()).ParseGlob(filepath.Join(dir, "*.html"))
+ if err != nil {
+ t.Fatalf("parsing top-level templates: %v", err)
+ }
+ if _, err := tmpl.ParseGlob(filepath.Join(dir, "partials", "*.html")); err != nil {
+ t.Fatalf("parsing partials: %v", err)
+ }
+ var buf bytes.Buffer
+ if err := tmpl.ExecuteTemplate(&buf, "shopping-items-content", data); err != nil {
+ t.Fatalf("executing shopping-items-content: %v", err)
+ }
+ return buf.String()
+}
+
func twoStoreTabData() any {
return struct {
Stores []models.ShoppingStore
@@ -73,21 +91,21 @@ func TestShoppingTab_InlineAddScrollsToItsOwnStore(t *testing.T) {
}
}
-// Every inline add form must carry a scroll target. A form without one is the
-// bug, and it is easy to reintroduce by adding a new store section by copy.
-func TestShoppingTab_EveryInlineAddFormHasAScrollTarget(t *testing.T) {
- html := renderShoppingTab(t, twoStoreTabData())
+// Every form that lives *inside* the swap target must carry a scroll modifier.
+// Those forms are destroyed by their own swap, and without an explicit target
+// the page lands at the bottom. Forms outside the target (the store-create
+// form) do not have this problem and are excluded by rendering only the items
+// content here.
+func TestShoppingItemsContent_EveryFormInsideTheSwapTargetScrolls(t *testing.T) {
+ html := renderShoppingItemsContent(t, twoStoreTabData())
- // Count the class attribute specifically — a bare "inline-add-form" also
- // matches the string literal in the page's own JS handler.
- forms := strings.Count(html, `class="inline-add-form`)
+ forms := strings.Count(html, `hx-target="#shopping-items-list"`)
scrolls := strings.Count(html, "scroll:#store-")
if forms == 0 {
- t.Fatal("expected at least one inline add form in the grouped view")
+ t.Fatal("expected forms inside the items content")
}
if forms != scrolls {
- t.Errorf("found %d inline add forms but %d scroll targets; every form needs one",
- forms, scrolls)
+ t.Errorf("%d forms target the items list but only %d carry a scroll target", forms, scrolls)
}
}