summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-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
-rw-r--r--internal/store/shopping.go69
-rw-r--r--internal/store/shopping_import_test.go1
-rw-r--r--internal/store/shopping_registry.go193
-rw-r--r--internal/store/shopping_registry_test.go302
-rw-r--r--internal/store/shopping_test.go1
9 files changed, 1089 insertions, 21 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)
}
}
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, &sectionName)
+ 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
);