diff options
| -rw-r--r-- | cmd/dashboard/main.go | 5 | ||||
| -rw-r--r-- | internal/handlers/shopping.go | 36 | ||||
| -rw-r--r-- | internal/handlers/shopping_edit.go | 232 | ||||
| -rw-r--r-- | internal/handlers/shopping_edit_test.go | 238 | ||||
| -rw-r--r-- | internal/handlers/shopping_template_test.go | 38 | ||||
| -rw-r--r-- | internal/store/shopping.go | 69 | ||||
| -rw-r--r-- | internal/store/shopping_import_test.go | 1 | ||||
| -rw-r--r-- | internal/store/shopping_registry.go | 193 | ||||
| -rw-r--r-- | internal/store/shopping_registry_test.go | 302 | ||||
| -rw-r--r-- | internal/store/shopping_test.go | 1 | ||||
| -rw-r--r-- | migrations/034_shopping_stores_and_sections.sql | 41 | ||||
| -rw-r--r-- | migrations/README.md | 3 | ||||
| -rw-r--r-- | web/templates/partials/shopping-item-edit.html | 114 | ||||
| -rw-r--r-- | web/templates/partials/shopping-tab.html | 40 |
14 files changed, 1289 insertions, 24 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 56251e0..f66e2c5 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -370,6 +370,11 @@ func main() { // Shopping quick-add r.Get("/partials/shopping-stores", h.HandleGetShoppingStores) r.Post("/shopping/add", h.HandleShoppingQuickAdd) + r.Get("/shopping/item/{id}/edit", h.HandleShoppingItemEdit) + r.Post("/shopping/item/{id}", h.HandleShoppingItemUpdate) + r.Delete("/shopping/item/{id}", h.HandleShoppingItemDelete) + r.Post("/shopping/stores", h.HandleShoppingStoreCreate) + r.Post("/shopping/sections", h.HandleShoppingSectionCreate) // Shopping mode (focused single-store view) r.Get("/shopping/mode/{store}", h.HandleShoppingMode) 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) } } diff --git a/internal/store/shopping.go b/internal/store/shopping.go index 40571ac..448a2be 100644 --- a/internal/store/shopping.go +++ b/internal/store/shopping.go @@ -1,24 +1,85 @@ package store -import "time" +import ( + "database/sql" + "errors" + "fmt" + "strings" + "time" +) type UserShoppingItem struct { ID int64 Name string Store string + Section string Checked bool CreatedAt time.Time } // SaveUserShoppingItem saves a new user shopping item func (s *Store) SaveUserShoppingItem(name, store string) error { - _, err := s.db.Exec(`INSERT INTO user_shopping_items (name, store) VALUES (?, ?)`, name, store) + return s.SaveUserShoppingItemInSection(name, store, "") +} + +// SaveUserShoppingItemInSection saves a new item filed under a section. +func (s *Store) SaveUserShoppingItemInSection(name, store, section string) error { + _, err := s.db.Exec( + `INSERT INTO user_shopping_items (name, store, section) VALUES (?, ?, ?)`, + strings.TrimSpace(name), strings.TrimSpace(store), strings.TrimSpace(section)) return err } +// UpdateUserShoppingItem renames an item and/or moves it between stores and +// sections. Everything an edit can change goes through here, so a partial +// update cannot leave an item half-moved. +func (s *Store) UpdateUserShoppingItem(id int64, name, store, section string) error { + name = strings.TrimSpace(name) + store = strings.TrimSpace(store) + section = strings.TrimSpace(section) + if name == "" { + return fmt.Errorf("item name is required") + } + if store == "" { + return fmt.Errorf("store is required") + } + + res, err := s.db.Exec( + `UPDATE user_shopping_items SET name = ?, store = ?, section = ? WHERE id = ?`, + name, store, section, id) + if err != nil { + return err + } + // An UPDATE that matches nothing succeeds at the SQL level, so a stale or + // wrong id would otherwise look like a successful edit. + if n, err := res.RowsAffected(); err == nil && n == 0 { + return ErrItemNotFound + } + return nil +} + +// GetUserShoppingItem returns a single item, or ErrItemNotFound. +func (s *Store) GetUserShoppingItem(id int64) (*UserShoppingItem, error) { + var item UserShoppingItem + err := s.db.QueryRow( + `SELECT id, name, store, COALESCE(section, ''), checked, created_at + FROM user_shopping_items WHERE id = ?`, id). + Scan(&item.ID, &item.Name, &item.Store, &item.Section, &item.Checked, &item.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrItemNotFound + } + if err != nil { + return nil, err + } + return &item, nil +} + +// ErrItemNotFound is returned when an item id matches nothing. +var ErrItemNotFound = errors.New("shopping item not found") + // GetUserShoppingItems retrieves all user shopping items func (s *Store) GetUserShoppingItems() ([]UserShoppingItem, error) { - rows, err := s.db.Query(`SELECT id, name, store, checked, created_at FROM user_shopping_items ORDER BY store, created_at DESC`) + rows, err := s.db.Query(`SELECT id, name, store, COALESCE(section, ''), checked, created_at FROM user_shopping_items ORDER BY store, created_at DESC`) if err != nil { return nil, err } @@ -27,7 +88,7 @@ func (s *Store) GetUserShoppingItems() ([]UserShoppingItem, error) { var items []UserShoppingItem for rows.Next() { var item UserShoppingItem - if err := rows.Scan(&item.ID, &item.Name, &item.Store, &item.Checked, &item.CreatedAt); err != nil { + if err := rows.Scan(&item.ID, &item.Name, &item.Store, &item.Section, &item.Checked, &item.CreatedAt); err != nil { return nil, err } items = append(items, item) diff --git a/internal/store/shopping_import_test.go b/internal/store/shopping_import_test.go index 0e246d8..87dee92 100644 --- a/internal/store/shopping_import_test.go +++ b/internal/store/shopping_import_test.go @@ -145,6 +145,7 @@ func setupImportTestDB(t *testing.T) *sql.DB { id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, store TEXT NOT NULL, + section TEXT NOT NULL DEFAULT '', checked INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); diff --git a/internal/store/shopping_registry.go b/internal/store/shopping_registry.go new file mode 100644 index 0000000..f3cc515 --- /dev/null +++ b/internal/store/shopping_registry.go @@ -0,0 +1,193 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +// ShoppingStore is a registered store, with its sections. +type ShoppingStore struct { + ID int64 + Name string + Sections []ShoppingSection +} + +// ShoppingSection is an aisle or grouping within a store. +type ShoppingSection struct { + ID int64 + StoreID int64 + Name string +} + +// ErrStoreNotFound is returned when an operation names a store that is not +// registered. Callers surface this rather than silently creating one, so a +// typo does not quietly produce a second store. +var ErrStoreNotFound = errors.New("store not found") + +// GetShoppingStores returns every registered store with its sections. +func (s *Store) GetShoppingStores() ([]ShoppingStore, error) { + rows, err := s.db.Query(`SELECT id, name FROM shopping_stores ORDER BY sort_order, name`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var stores []ShoppingStore + byID := map[int64]int{} + for rows.Next() { + var st ShoppingStore + if err := rows.Scan(&st.ID, &st.Name); err != nil { + return nil, err + } + byID[st.ID] = len(stores) + stores = append(stores, st) + } + if err := rows.Err(); err != nil { + return nil, err + } + + secRows, err := s.db.Query(`SELECT id, store_id, name FROM shopping_sections ORDER BY sort_order, name`) + if err != nil { + return nil, err + } + defer func() { _ = secRows.Close() }() + + for secRows.Next() { + var sec ShoppingSection + if err := secRows.Scan(&sec.ID, &sec.StoreID, &sec.Name); err != nil { + return nil, err + } + if idx, ok := byID[sec.StoreID]; ok { + stores[idx].Sections = append(stores[idx].Sections, sec) + } + } + return stores, secRows.Err() +} + +// AddShoppingStore registers a store. Adding one that already exists is a +// no-op rather than an error — the intent ("there should be a store called +// this") is already satisfied. +func (s *Store) AddShoppingStore(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("store name is required") + } + _, err := s.db.Exec(`INSERT OR IGNORE INTO shopping_stores (name) VALUES (?)`, name) + return err +} + +// RenameShoppingStore renames a store and moves its items with it. +// +// Items reference a store by name, so the two have to change together or every +// item in the store is orphaned. Done in one transaction for that reason. +func (s *Store) RenameShoppingStore(id int64, newName string) error { + newName = strings.TrimSpace(newName) + if newName == "" { + return fmt.Errorf("store name is required") + } + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var oldName string + if err := tx.QueryRow(`SELECT name FROM shopping_stores WHERE id = ?`, id).Scan(&oldName); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrStoreNotFound + } + return err + } + if _, err := tx.Exec(`UPDATE shopping_stores SET name = ? WHERE id = ?`, newName, id); err != nil { + return err + } + if _, err := tx.Exec(`UPDATE user_shopping_items SET store = ? WHERE store = ?`, newName, oldName); err != nil { + return err + } + return tx.Commit() +} + +// DeleteShoppingStore removes a store. Its items are not deleted; they are +// moved to unassigned so nothing silently disappears with the store. +func (s *Store) DeleteShoppingStore(id int64) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var name string + if err := tx.QueryRow(`SELECT name FROM shopping_stores WHERE id = ?`, id).Scan(&name); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrStoreNotFound + } + return err + } + if _, err := tx.Exec( + `UPDATE user_shopping_items SET store = 'Unassigned', section = '' WHERE store = ?`, name); err != nil { + return err + } + // Sections go with the store via ON DELETE CASCADE. + if _, err := tx.Exec(`DELETE FROM shopping_stores WHERE id = ?`, id); err != nil { + return err + } + return tx.Commit() +} + +// AddShoppingSection adds a section to a store. +func (s *Store) AddShoppingSection(storeID int64, name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("section name is required") + } + _, err := s.db.Exec( + `INSERT OR IGNORE INTO shopping_sections (store_id, name) VALUES (?, ?)`, storeID, name) + return err +} + +// DeleteShoppingSection removes a section, clearing it from its items rather +// than deleting them. +func (s *Store) DeleteShoppingSection(id int64) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var storeName, sectionName string + err = tx.QueryRow(` + SELECT st.name, se.name + FROM shopping_sections se JOIN shopping_stores st ON st.id = se.store_id + WHERE se.id = ?`, id).Scan(&storeName, §ionName) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil // already gone + } + return err + } + if _, err := tx.Exec( + `UPDATE user_shopping_items SET section = '' WHERE store = ? AND section = ?`, + storeName, sectionName); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM shopping_sections WHERE id = ?`, id); err != nil { + return err + } + return tx.Commit() +} + +// ShoppingStoreNames returns just the registered store names, for pickers. +func (s *Store) ShoppingStoreNames() ([]string, error) { + stores, err := s.GetShoppingStores() + if err != nil { + return nil, err + } + names := make([]string, 0, len(stores)) + for _, st := range stores { + names = append(names, st.Name) + } + return names, nil +} diff --git a/internal/store/shopping_registry_test.go b/internal/store/shopping_registry_test.go new file mode 100644 index 0000000..b8ed79e --- /dev/null +++ b/internal/store/shopping_registry_test.go @@ -0,0 +1,302 @@ +package store + +import ( + "database/sql" + "errors" + "path/filepath" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func setupRegistryStore(t *testing.T) *Store { + t.Helper() + db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "reg.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + db.SetMaxOpenConns(1) + if _, err := db.Exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE user_shopping_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + store TEXT NOT NULL, + section TEXT NOT NULL DEFAULT '', + checked INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE shopping_stores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE shopping_sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + store_id INTEGER NOT NULL REFERENCES shopping_stores(id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (store_id, name) + );`); err != nil { + t.Fatalf("schema: %v", err) + } + return &Store{db: db} +} + +// The registry's whole purpose: a store exists whether or not anything is in +// it, so you can move an item *to* an empty store. +func TestShoppingStores_ExistWithoutItems(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + + if err := s.AddShoppingStore("Hardware Store"); err != nil { + t.Fatalf("add: %v", err) + } + names, err := s.ShoppingStoreNames() + if err != nil { + t.Fatalf("names: %v", err) + } + if len(names) != 1 || names[0] != "Hardware Store" { + t.Errorf("got %v, want [Hardware Store]", names) + } +} + +func TestShoppingStores_AddIsIdempotent(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + + _ = s.AddShoppingStore("Costco") + if err := s.AddShoppingStore("Costco"); err != nil { + t.Fatalf("re-add should be a no-op, got: %v", err) + } + names, _ := s.ShoppingStoreNames() + if len(names) != 1 { + t.Errorf("got %d stores, want 1", len(names)) + } +} + +func TestShoppingStores_AddRejectsBlank(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + if err := s.AddShoppingStore(" "); err == nil { + t.Error("expected an error for a blank store name") + } +} + +// Items reference a store by name, so a rename must carry them along or every +// item in that store is orphaned. +func TestShoppingStores_RenameMovesItsItems(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + + _ = s.AddShoppingStore("Hardware") + _ = s.SaveUserShoppingItem("Nails", "Hardware") + _ = s.SaveUserShoppingItem("Milk", "Costco") + + stores, _ := s.GetShoppingStores() + if err := s.RenameShoppingStore(stores[0].ID, "Hardware Store"); err != nil { + t.Fatalf("rename: %v", err) + } + + items, _ := s.GetUserShoppingItems() + for _, it := range items { + if it.Name == "Nails" && it.Store != "Hardware Store" { + t.Errorf("item stayed on the old store name: %+v", it) + } + if it.Name == "Milk" && it.Store != "Costco" { + t.Errorf("an unrelated item was moved: %+v", it) + } + } +} + +func TestShoppingStores_RenameUnknownStore(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + if err := s.RenameShoppingStore(999, "Nope"); !errors.Is(err, ErrStoreNotFound) { + t.Errorf("got %v, want ErrStoreNotFound", err) + } +} + +// Deleting a store must not take its items with it — losing the shopping list +// is a much worse outcome than an item needing to be re-filed. +func TestShoppingStores_DeleteKeepsItems(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + + _ = s.AddShoppingStore("Hardware") + _ = s.SaveUserShoppingItemInSection("Nails", "Hardware", "Fasteners") + + stores, _ := s.GetShoppingStores() + if err := s.DeleteShoppingStore(stores[0].ID); err != nil { + t.Fatalf("delete: %v", err) + } + + items, _ := s.GetUserShoppingItems() + if len(items) != 1 { + t.Fatalf("got %d items, want the item kept", len(items)) + } + if items[0].Store != "Unassigned" || items[0].Section != "" { + t.Errorf("got %+v, want it moved to Unassigned with no section", items[0]) + } +} + +func TestShoppingSections_AddedAndReturnedWithTheirStore(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + + _ = s.AddShoppingStore("Costco") + stores, _ := s.GetShoppingStores() + if err := s.AddShoppingSection(stores[0].ID, "Produce"); err != nil { + t.Fatalf("add section: %v", err) + } + _ = s.AddShoppingSection(stores[0].ID, "Frozen") + + stores, _ = s.GetShoppingStores() + if len(stores[0].Sections) != 2 { + t.Fatalf("got %d sections, want 2", len(stores[0].Sections)) + } +} + +// The same section name in two different stores is normal ("Produce" exists in +// both) and must not collide. +func TestShoppingSections_SameNameInDifferentStores(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + + _ = s.AddShoppingStore("Costco") + _ = s.AddShoppingStore("Safeway") + stores, _ := s.GetShoppingStores() + for _, st := range stores { + if err := s.AddShoppingSection(st.ID, "Produce"); err != nil { + t.Fatalf("add section to %s: %v", st.Name, err) + } + } + stores, _ = s.GetShoppingStores() + for _, st := range stores { + if len(st.Sections) != 1 { + t.Errorf("%s has %d sections, want 1", st.Name, len(st.Sections)) + } + } +} + +func TestShoppingSections_DeleteClearsItFromItems(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + + _ = s.AddShoppingStore("Costco") + stores, _ := s.GetShoppingStores() + _ = s.AddShoppingSection(stores[0].ID, "Produce") + _ = s.SaveUserShoppingItemInSection("Kale", "Costco", "Produce") + + stores, _ = s.GetShoppingStores() + if err := s.DeleteShoppingSection(stores[0].Sections[0].ID); err != nil { + t.Fatalf("delete section: %v", err) + } + + items, _ := s.GetUserShoppingItems() + if len(items) != 1 { + t.Fatalf("item was deleted along with its section") + } + if items[0].Section != "" { + t.Errorf("Section = %q, want cleared", items[0].Section) + } + if items[0].Store != "Costco" { + t.Errorf("Store = %q, want unchanged", items[0].Store) + } +} + +// Deleting a store's section must cascade, not leave orphaned rows pointing at +// a store that no longer exists. +func TestShoppingSections_CascadeWhenStoreDeleted(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + + _ = s.AddShoppingStore("Costco") + stores, _ := s.GetShoppingStores() + _ = s.AddShoppingSection(stores[0].ID, "Produce") + + if err := s.DeleteShoppingStore(stores[0].ID); err != nil { + t.Fatalf("delete store: %v", err) + } + var n int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM shopping_sections`).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Errorf("%d orphaned sections remain", n) + } +} + +// ── item editing ───────────────────────────────────────────────────────────── + +func TestUpdateUserShoppingItem_RenamesAndMoves(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + + _ = s.SaveUserShoppingItemInSection("Mlik", "Costco", "Dairy") + items, _ := s.GetUserShoppingItems() + + if err := s.UpdateUserShoppingItem(items[0].ID, "Milk", "Safeway", "Refrigerated"); err != nil { + t.Fatalf("update: %v", err) + } + + got, err := s.GetUserShoppingItem(items[0].ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Name != "Milk" || got.Store != "Safeway" || got.Section != "Refrigerated" { + t.Errorf("got %+v, want renamed and moved", got) + } +} + +func TestUpdateUserShoppingItem_ClearingTheSection(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + + _ = s.SaveUserShoppingItemInSection("Kale", "Costco", "Produce") + items, _ := s.GetUserShoppingItems() + + if err := s.UpdateUserShoppingItem(items[0].ID, "Kale", "Costco", ""); err != nil { + t.Fatalf("update: %v", err) + } + got, _ := s.GetUserShoppingItem(items[0].ID) + if got.Section != "" { + t.Errorf("Section = %q, want cleared", got.Section) + } +} + +// A SQL UPDATE that matches nothing still succeeds, so a stale id would look +// like a successful edit and the user would think the change stuck. +func TestUpdateUserShoppingItem_UnknownIDIsAnError(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + + if err := s.UpdateUserShoppingItem(999, "Ghost", "Costco", ""); !errors.Is(err, ErrItemNotFound) { + t.Errorf("got %v, want ErrItemNotFound", err) + } +} + +func TestUpdateUserShoppingItem_RejectsBlankNameOrStore(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + + _ = s.SaveUserShoppingItem("Milk", "Costco") + items, _ := s.GetUserShoppingItems() + + if err := s.UpdateUserShoppingItem(items[0].ID, " ", "Costco", ""); err == nil { + t.Error("expected an error for a blank name") + } + if err := s.UpdateUserShoppingItem(items[0].ID, "Milk", " ", ""); err == nil { + t.Error("expected an error for a blank store") + } +} + +func TestGetUserShoppingItem_NotFound(t *testing.T) { + s := setupRegistryStore(t) + defer func() { _ = s.Close() }() + if _, err := s.GetUserShoppingItem(999); !errors.Is(err, ErrItemNotFound) { + t.Errorf("got %v, want ErrItemNotFound", err) + } +} diff --git a/internal/store/shopping_test.go b/internal/store/shopping_test.go index 01f106c..3dfd2c2 100644 --- a/internal/store/shopping_test.go +++ b/internal/store/shopping_test.go @@ -27,6 +27,7 @@ func setupTestStoreWithShopping(t *testing.T) *Store { id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, store TEXT NOT NULL, + section TEXT NOT NULL DEFAULT '', checked INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); diff --git a/migrations/034_shopping_stores_and_sections.sql b/migrations/034_shopping_stores_and_sections.sql new file mode 100644 index 0000000..2b47846 --- /dev/null +++ b/migrations/034_shopping_stores_and_sections.sql @@ -0,0 +1,41 @@ +-- A real registry for shopping stores and sections. +-- +-- Until now a store existed only because some item referenced it by name. +-- Trello's list names were quietly acting as the registry; removing Trello as +-- a shopping source (migration 032) made the gap visible. Without this you +-- cannot move an item to a store that is currently empty, and moving the last +-- item out of a store makes that store vanish mid-edit. +-- +-- Sections are the same idea one level down. They previously existed only as +-- PlanToEat's category strings, so a user item could not be filed under one at +-- all. + +CREATE TABLE IF NOT EXISTS shopping_stores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS shopping_sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + store_id INTEGER NOT NULL REFERENCES shopping_stores(id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (store_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_shopping_sections_store ON shopping_sections(store_id); + +-- Items gain a section. Store stays a name rather than a foreign key: items +-- also arrive from PlanToEat carrying a store name that may not be registered +-- yet, and a NOT NULL reference would mean either dropping those items or +-- auto-creating stores from scraped data. +ALTER TABLE user_shopping_items ADD COLUMN section TEXT NOT NULL DEFAULT ''; + +-- Seed the registry from the stores that currently exist, so nothing +-- disappears the moment the registry becomes authoritative. +INSERT OR IGNORE INTO shopping_stores (name) +SELECT DISTINCT TRIM(store) FROM user_shopping_items +WHERE TRIM(COALESCE(store, '')) <> ''; diff --git a/migrations/README.md b/migrations/README.md index 1564b48..07320af 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -29,10 +29,11 @@ Migration 007 was reserved during development but never implemented. The gap is Migrations are applied automatically on application startup via `store.RunMigrations()`. The `schema_migrations` table tracks which migrations have been applied. | 032 | `032_import_trello_shopping_items.sql` | One-time import of Trello Shopping-board cards (Hardware Store + unlisted) into user items, ahead of removing Trello as a shopping source | | 033 | `033_ics_feeds.sql` | Subscribed iCalendar (.ics) feeds, merged into calendar_events | +| 034 | `034_shopping_stores_and_sections.sql` | Store/section registry + `user_shopping_items.section` | ## Creating New Migrations -1. Create file: `migrations/NNN_description.sql` (next number is 034) +1. Create file: `migrations/NNN_description.sql` (next number is 035) 2. Write idempotent SQL (use `IF NOT EXISTS` where possible) 3. Test locally: `go run cmd/dashboard/main.go` 4. Verify: Check `schema_migrations` table for new entry diff --git a/web/templates/partials/shopping-item-edit.html b/web/templates/partials/shopping-item-edit.html new file mode 100644 index 0000000..e5220f5 --- /dev/null +++ b/web/templates/partials/shopping-item-edit.html @@ -0,0 +1,114 @@ +{{define "shopping-item-edit"}} +<div id="shopping-edit-backdrop" + class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm p-0 sm:p-4" + onclick="if(event.target === this) this.remove()"> + <div class="w-full sm:max-w-md bg-slate-900 border border-white/10 rounded-t-2xl sm:rounded-2xl p-5 space-y-4"> + + <div class="flex items-start justify-between gap-3"> + <h3 class="text-lg font-medium text-white">{{if .Editable}}Edit item{{else}}Item{{end}}</h3> + <button type="button" class="text-white/40 hover:text-white text-xl leading-none" + onclick="document.getElementById('shopping-edit-backdrop').remove()">×</button> + </div> + + {{if .Editable}} + <form hx-post="/shopping/item/{{.ID}}" + hx-target="#shopping-items-list" + hx-swap="innerHTML" + hx-on::after-request="if(event.detail.successful) document.getElementById('shopping-edit-backdrop').remove()" + class="space-y-4"> + + <div> + <label class="block text-xs uppercase tracking-wide text-white/50 mb-1">Name</label> + <input type="text" name="name" value="{{.Name}}" required autocomplete="off" + class="w-full bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-white/30"> + </div> + + <div> + <label class="block text-xs uppercase tracking-wide text-white/50 mb-1">Store</label> + <select name="store" id="edit-store-select" required + class="w-full bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-white/30"> + {{range .Stores}} + <option value="{{.Name}}" {{if .Selected}}selected{{end}}>{{.Name}}</option> + {{end}} + </select> + </div> + + <div> + <label class="block text-xs uppercase tracking-wide text-white/50 mb-1">Section</label> + <select name="section" id="edit-section-select" + class="w-full bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-white/30"> + <option value="">— none —</option> + </select> + <p class="text-xs text-white/30 mt-1">Sections belong to a store; changing store changes the options.</p> + </div> + + <div class="flex gap-2 pt-1"> + <button type="submit" + class="flex-1 bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"> + Save + </button> + <button type="button" + class="px-4 py-2 rounded-lg text-sm border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors" + hx-delete="/shopping/item/{{.ID}}" + hx-target="#shopping-items-list" + hx-swap="innerHTML" + hx-confirm="Delete {{.Name}}?" + hx-on::after-request="if(event.detail.successful) document.getElementById('shopping-edit-backdrop').remove()"> + Delete + </button> + </div> + </form> + + <script> + (function() { + // Section options are per-store, so they are rebuilt whenever the + // store changes rather than shipped as one flat list — otherwise + // you could file a Costco item under a Safeway aisle. + var sectionsByStore = { + {{- range .Stores}} + {{.Name}}: [{{range $i, $s := .Sections}}{{if $i}}, {{end}}{{$s.Name}}{{end}}], + {{- end}} + }; + var current = {{.Section}}; + var storeSel = document.getElementById('edit-store-select'); + var sectionSel = document.getElementById('edit-section-select'); + + function rebuild() { + var opts = sectionsByStore[storeSel.value] || []; + sectionSel.innerHTML = '<option value="">— none —</option>'; + opts.forEach(function(name) { + var o = document.createElement('option'); + o.value = name; + o.textContent = name; + if (name === current) o.selected = true; + sectionSel.appendChild(o); + }); + } + storeSel.addEventListener('change', function() { current = ''; rebuild(); }); + rebuild(); + })(); + </script> + + {{else}} + <div class="space-y-3"> + <div> + <div class="text-white">{{.Name}}</div> + <div class="text-sm text-white/50">{{.Store}}</div> + </div> + <p class="text-sm text-amber-300/80 bg-amber-500/10 border border-amber-500/20 rounded-lg p-3"> + {{.ReadOnlyWhy}} + </p> + <button type="button" + class="w-full px-4 py-2 rounded-lg text-sm border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors" + hx-delete="/shopping/item/{{.ID}}?source={{.Source}}" + hx-target="#shopping-items-list" + hx-swap="innerHTML" + hx-confirm="Remove {{.Name}} from the list?" + hx-on::after-request="if(event.detail.successful) document.getElementById('shopping-edit-backdrop').remove()"> + Remove from list + </button> + </div> + {{end}} + </div> +</div> +{{end}} diff --git a/web/templates/partials/shopping-tab.html b/web/templates/partials/shopping-tab.html index 9d49122..4937b73 100644 --- a/web/templates/partials/shopping-tab.html +++ b/web/templates/partials/shopping-tab.html @@ -53,6 +53,21 @@ </div> </form> + <!-- New store --> + <form class="flex gap-2" + hx-post="/shopping/stores" + hx-target="#shopping-items-list" + hx-swap="innerHTML" + hx-on::after-request="if(event.detail.successful) this.reset()"> + <input type="hidden" name="grouped" value="{{.Grouped}}"> + <input type="text" name="name" placeholder="New store..." + class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-white/20 text-sm" + required autocomplete="off"> + <button type="submit" class="bg-white/10 hover:bg-white/20 text-white/70 hover:text-white px-4 py-2 rounded-lg text-sm transition-colors"> + Add store + </button> + </form> + <!-- Items List Container --> <div id="shopping-items-list"> {{template "shopping-items-content" .}} @@ -114,7 +129,10 @@ <ul class="space-y-2"> {{range .Items}} {{if not .Checked}} - <li class="flex items-center gap-3 p-3 bg-card bg-card-hover transition-colors rounded-lg border border-white/5"> + <li class="flex items-center gap-3 p-3 bg-card bg-card-hover transition-colors rounded-lg border border-white/5 cursor-pointer" + hx-get="/shopping/item/{{.ID}}/edit?source={{.Source}}&name={{.Name}}&store={{.Store}}" + hx-target="body" + hx-swap="beforeend"> <span class="flex-1 text-white">{{.Name}}</span> {{if .Quantity}}<span class="text-white/50 text-sm">{{.Quantity}}</span>{{end}} <span class="text-xs px-2 py-0.5 rounded {{if eq .Source "user"}}bg-purple-900/50 text-purple-300{{else}}bg-green-900/50 text-green-300{{end}}"> @@ -126,6 +144,21 @@ </ul> </div> {{end}} + <!-- Add a section to this store --> + <form class="mt-3 flex gap-2" + hx-post="/shopping/sections" + hx-target="#shopping-items-list" + hx-swap="innerHTML scroll:#store-{{slug .Name}}:top"> + <input type="hidden" name="store" value="{{.Name}}"> + <input type="hidden" name="grouped" value="true"> + <input type="text" name="name" placeholder="New section in {{.Name}}..." + class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-white/20 text-xs" + required autocomplete="off"> + <button type="submit" class="bg-white/5 hover:bg-white/15 text-white/50 hover:text-white px-3 py-2 rounded-lg text-xs transition-colors"> + + section + </button> + </form> + <!-- Inline Add Item --> <!-- scroll: is not optional here. This form sits *inside* the swap target, so submitting it replaces the form mid-request; without an @@ -155,7 +188,10 @@ {{range .Categories}} {{range .Items}} {{if not .Checked}} - <li class="flex items-center gap-3 p-3 bg-card bg-card-hover transition-colors rounded-lg border border-white/5"> + <li class="flex items-center gap-3 p-3 bg-card bg-card-hover transition-colors rounded-lg border border-white/5 cursor-pointer" + hx-get="/shopping/item/{{.ID}}/edit?source={{.Source}}&name={{.Name}}&store={{.Store}}" + hx-target="body" + hx-swap="beforeend"> <span class="flex-1 text-white">{{.Name}}</span> {{if .Quantity}}<span class="text-white/50 text-sm">{{.Quantity}}</span>{{end}} <span class="text-xs px-2 py-0.5 rounded bg-white/10 text-white/50"> |
