summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/dashboard/main.go2
-rw-r--r--internal/handlers/handlers.go14
-rw-r--r--internal/handlers/handlers_test.go15
-rw-r--r--internal/handlers/shopping.go89
-rw-r--r--internal/handlers/shopping_sources_test.go159
-rw-r--r--internal/store/shopping_import_test.go229
-rw-r--r--migrations/032_import_trello_shopping_items.sql46
-rw-r--r--migrations/README.md3
-rw-r--r--web/templates/index.html15
-rw-r--r--web/templates/partials/shopping-mode-items.html3
-rw-r--r--web/templates/partials/shopping-tab.html4
11 files changed, 493 insertions, 86 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index b6518c4..bfd890a 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -357,7 +357,6 @@ func main() {
// Unified Quick Add (for Tasks tab)
r.Post("/unified-add", h.HandleUnifiedAdd)
- r.Get("/partials/shopping-lists", h.HandleGetShoppingLists)
// Task detail/edit
r.Get("/tasks/detail", h.HandleGetTaskDetail)
@@ -369,6 +368,7 @@ func main() {
r.Delete("/tasks/{id}", h.HandleDeleteTask)
// Shopping quick-add
+ r.Get("/partials/shopping-stores", h.HandleGetShoppingStores)
r.Post("/shopping/add", h.HandleShoppingQuickAdd)
// Shopping mode (focused single-store view)
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index c5df00d..147db07 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -715,6 +715,20 @@ func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) {
}
_ = h.store.InvalidateCache(store.CacheKeyTrelloBoards)
+ case "shopping":
+ // The quick-action modal's Shopping tab. This used to create a Trello
+ // card on the Shopping board; that board is no longer a shopping
+ // source, so an item added there would have vanished from the list.
+ storeName := strings.TrimSpace(r.FormValue("store"))
+ if storeName == "" {
+ JSONError(w, http.StatusBadRequest, "Store is required for a shopping item", nil)
+ return
+ }
+ if err := h.store.SaveUserShoppingItem(title, storeName); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to save shopping item", err)
+ return
+ }
+
default:
JSONError(w, http.StatusBadRequest, "Invalid source", nil)
return
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index 61fb75d..7630e5d 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -480,11 +480,13 @@ func TestHandleShoppingModeComplete(t *testing.T) {
}
})
+ // PlanToEat is the only remaining external source; Trello was removed as a
+ // shopping source, so completing one of its items is no longer meaningful.
t.Run("complete external item marks it checked", func(t *testing.T) {
req := httptest.NewRequest("POST", "/shopping/mode/TestStore/complete", nil)
req.Form = map[string][]string{
- "id": {"trello-123"},
- "source": {"trello"},
+ "id": {"pte-123"},
+ "source": {"plantoeat"},
}
rctx := chi.NewRouteContext()
@@ -499,9 +501,9 @@ func TestHandleShoppingModeComplete(t *testing.T) {
}
// Verify item is marked as checked
- checks, _ := db.GetShoppingItemChecks("trello")
- if !checks["trello-123"] {
- t.Error("Expected trello item to be marked as checked")
+ checks, _ := db.GetShoppingItemChecks("plantoeat")
+ if !checks["pte-123"] {
+ t.Error("Expected plantoeat item to be marked as checked")
}
})
}
@@ -787,7 +789,8 @@ func TestShoppingComplete_ExternalItemMarkedChecked(t *testing.T) {
source string
itemID string
}{
- {"trello", "trello-card-123"},
+ // Trello is no longer a shopping source; plantoeat is the only
+ // external one whose items can be checked off.
{"plantoeat", "pte-item-456"},
}
diff --git a/internal/handlers/shopping.go b/internal/handlers/shopping.go
index 1c58126..e06cad6 100644
--- a/internal/handlers/shopping.go
+++ b/internal/handlers/shopping.go
@@ -16,7 +16,7 @@ import (
"task-dashboard/internal/models"
)
-// HandleTabShopping renders the Shopping tab (Trello Shopping board + PlanToEat + User items)
+// HandleTabShopping renders the Shopping tab (PlanToEat + user-added items)
func (h *Handler) HandleTabShopping(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
stores := h.aggregateShoppingLists(ctx)
@@ -125,7 +125,7 @@ func (h *Handler) HandleShoppingModeComplete(w http.ResponseWriter, r *http.Requ
JSONError(w, http.StatusInternalServerError, "Failed to delete item", err)
return
}
- case "trello", "plantoeat":
+ case "plantoeat":
// Mark as checked (will be filtered out of view)
if err := h.store.SetShoppingItemChecked(source, id, true); err != nil {
JSONError(w, http.StatusInternalServerError, "Failed to complete item", err)
@@ -160,81 +160,34 @@ func (h *Handler) HandleShoppingModeComplete(w http.ResponseWriter, r *http.Requ
}{storeName, items})
}
-// HandleGetShoppingLists returns the lists from the Shopping board for quick-add
-func (h *Handler) HandleGetShoppingLists(w http.ResponseWriter, r *http.Request) {
- boards, err := h.store.GetBoards()
- if err != nil {
- JSONError(w, http.StatusInternalServerError, "Failed to get boards", err)
- return
- }
-
- var shoppingBoardID string
- for _, b := range boards {
- if strings.EqualFold(b.Name, "Shopping") {
- shoppingBoardID = b.ID
- break
- }
- }
-
- if shoppingBoardID == "" {
- JSONError(w, http.StatusNotFound, "Shopping board not found", nil)
- return
- }
-
- lists, err := h.trelloClient.GetLists(r.Context(), shoppingBoardID)
- if err != nil {
- JSONError(w, http.StatusInternalServerError, "Failed to get lists", err)
- return
- }
+// HandleGetShoppingStores returns the current store names as <option> elements
+// for the quick-action modal's store picker.
+//
+// This replaces the Trello-lists endpoint the modal used to call. Note the
+// store names come from the items themselves — there is no separate store
+// registry yet, so a store with nothing in it does not appear here.
+func (h *Handler) HandleGetShoppingStores(w http.ResponseWriter, r *http.Request) {
+ stores := h.aggregateShoppingLists(r.Context())
w.Header().Set("Content-Type", "text/html")
- for _, list := range lists {
+ for _, s := range stores {
_, _ = fmt.Fprintf(w, `<option value="%s">%s</option>`,
- template.HTMLEscapeString(list.ID),
- template.HTMLEscapeString(list.Name))
+ template.HTMLEscapeString(s.Name),
+ template.HTMLEscapeString(s.Name))
}
}
-// aggregateShoppingLists combines Trello, PlanToEat, and user shopping items by store
+// aggregateShoppingLists combines PlanToEat and user shopping items by store
func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingStore {
storeMap := make(map[string]map[string][]models.UnifiedShoppingItem)
- // Fetch locally stored checked states for external sources
- trelloChecks, _ := h.store.GetShoppingItemChecks("trello")
+ // Fetch locally stored checked states for the remaining external source.
+ // Trello is no longer a shopping source: its items were read-only here and
+ // its list names were doubling as an accidental store registry. See
+ // migration 032 for the one-time import that preceded removing it.
plantoeatChecks, _ := h.store.GetShoppingItemChecks("plantoeat")
- // 1. Fetch Trello "Shopping" board cards
- boards, err := h.store.GetBoards()
- if err != nil {
- log.Printf("ERROR [Shopping/Trello]: %v", err)
- } else {
- for _, board := range boards {
- if strings.EqualFold(board.Name, "Shopping") {
- log.Printf("DEBUG [Shopping]: Found Shopping board with %d cards", len(board.Cards))
- for _, card := range board.Cards {
- storeName := card.ListName
- if storeName == "" {
- storeName = "Uncategorized"
- }
-
- if storeMap[storeName] == nil {
- storeMap[storeName] = make(map[string][]models.UnifiedShoppingItem)
- }
-
- item := models.UnifiedShoppingItem{
- ID: card.ID,
- Name: card.Name,
- Store: storeName,
- Source: "trello",
- Checked: trelloChecks[card.ID],
- }
- storeMap[storeName][""] = append(storeMap[storeName][""], item)
- }
- }
- }
- }
-
- // 2. Fetch PlanToEat shopping list
+ // 1. Fetch PlanToEat shopping list
if h.planToEatClient != nil {
items, err := h.planToEatClient.GetShoppingList(ctx)
if err != nil {
@@ -272,7 +225,7 @@ func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingS
log.Printf("DEBUG [Shopping/PlanToEat]: Client not configured")
}
- // 3. Fetch user-added shopping items
+ // 2. Fetch user-added shopping items
userItems, err := h.store.GetUserShoppingItems()
if err != nil {
log.Printf("ERROR [Shopping/User]: %v", err)
@@ -298,7 +251,7 @@ func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingS
}
}
- // 4. Convert map to sorted slice
+ // 3. 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_sources_test.go b/internal/handlers/shopping_sources_test.go
new file mode 100644
index 0000000..517f4b4
--- /dev/null
+++ b/internal/handlers/shopping_sources_test.go
@@ -0,0 +1,159 @@
+package handlers
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "task-dashboard/internal/models"
+)
+
+// The Trello "Shopping" board is no longer a source for the shopping list. It
+// had become a dumping ground, its items were read-only in doot (no rename, no
+// move, no delete — only hide), and its list names were doubling as an
+// accidental store registry. The two groups worth keeping were imported into
+// user items by migration 032; the rest simply stop being displayed. The cards
+// themselves are untouched in Trello.
+func TestAggregateShoppingLists_IgnoresTrelloShoppingBoard(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.SaveBoards([]models.Board{{
+ ID: "shop-board",
+ Name: "Shopping",
+ Cards: []models.Card{
+ {ID: "c1", Name: "Broccoli", ListName: ""},
+ {ID: "c2", Name: "Wasp traps", ListName: "Hardware Store"},
+ {ID: "c3", Name: "Pearl Waffles", ListName: "Costco"},
+ },
+ }}); err != nil {
+ t.Fatalf("seeding boards: %v", err)
+ }
+
+ stores := h.aggregateShoppingLists(context.Background())
+
+ for _, st := range stores {
+ for _, cat := range st.Categories {
+ for _, it := range cat.Items {
+ if it.Source == "trello" {
+ t.Errorf("trello item %q leaked into the shopping list (store %q)", it.Name, st.Name)
+ }
+ }
+ }
+ }
+}
+
+// User-added items are still aggregated — this is the source that remains, so
+// a regression here would empty the list entirely.
+func TestAggregateShoppingLists_IncludesUserItems(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.SaveUserShoppingItem("Milk", "Costco"); err != nil {
+ t.Fatalf("saving user item: %v", err)
+ }
+
+ stores := h.aggregateShoppingLists(context.Background())
+
+ var found bool
+ for _, st := range stores {
+ for _, cat := range st.Categories {
+ for _, it := range cat.Items {
+ if it.Name == "Milk" && it.Source == "user" && st.Name == "Costco" {
+ found = true
+ }
+ }
+ }
+ }
+ if !found {
+ t.Errorf("user item not found in aggregated list; got %+v", stores)
+ }
+}
+
+// A Shopping board with cards must not conjure stores into existence. Store
+// names used to come partly from Trello list names, which is why removing the
+// source changes which stores exist at all.
+func TestAggregateShoppingLists_TrelloListsDoNotCreateStores(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.SaveBoards([]models.Board{{
+ ID: "shop-board",
+ Name: "Shopping",
+ Cards: []models.Card{
+ {ID: "c1", Name: "Gas valve", ListName: "Hardware Store"},
+ },
+ }}); err != nil {
+ t.Fatalf("seeding boards: %v", err)
+ }
+
+ for _, st := range h.aggregateShoppingLists(context.Background()) {
+ if st.Name == "Hardware Store" {
+ t.Error(`"Hardware Store" store exists only because of a Trello list name`)
+ }
+ }
+}
+
+// The quick-action modal's Shopping tab posts source=shopping. It used to post
+// source=trello with a list_id, which after the Trello removal would have
+// created a card nobody could see.
+func TestUnifiedAdd_ShoppingSourceCreatesUserItem(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ form := url.Values{"title": {"Sponges"}, "source": {"shopping"}, "store": {"Costco"}}
+ req := httptest.NewRequest(http.MethodPost, "/unified-add", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+
+ h.HandleUnifiedAdd(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("got status %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ items, err := h.store.GetUserShoppingItems()
+ if err != nil {
+ t.Fatalf("reading items: %v", err)
+ }
+ if len(items) != 1 || items[0].Name != "Sponges" || items[0].Store != "Costco" {
+ t.Errorf("got %+v, want one Sponges/Costco item", items)
+ }
+}
+
+func TestUnifiedAdd_ShoppingRequiresAStore(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ form := url.Values{"title": {"Sponges"}, "source": {"shopping"}}
+ req := httptest.NewRequest(http.MethodPost, "/unified-add", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+
+ h.HandleUnifiedAdd(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("got status %d, want 400 when no store is given", rec.Code)
+ }
+}
+
+// The store picker must offer the stores that actually exist.
+func TestHandleGetShoppingStores_ReturnsOptions(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ _ = h.store.SaveUserShoppingItem("Milk", "Costco")
+ _ = h.store.SaveUserShoppingItem("Nails", "Hardware Store")
+
+ rec := httptest.NewRecorder()
+ h.HandleGetShoppingStores(rec, httptest.NewRequest(http.MethodGet, "/partials/shopping-stores", nil))
+
+ body := rec.Body.String()
+ for _, want := range []string{`<option value="Costco">Costco</option>`, `<option value="Hardware Store">Hardware Store</option>`} {
+ if !strings.Contains(body, want) {
+ t.Errorf("missing %q in %q", want, body)
+ }
+ }
+}
diff --git a/internal/store/shopping_import_test.go b/internal/store/shopping_import_test.go
new file mode 100644
index 0000000..0e246d8
--- /dev/null
+++ b/internal/store/shopping_import_test.go
@@ -0,0 +1,229 @@
+package store
+
+import (
+ "database/sql"
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+// Runs migrations/032 against a database seeded with a realistic snapshot of
+// the Trello Shopping board, and asserts it imports exactly the two groups
+// that were asked for and leaves the rest behind.
+//
+// The migration is executed from the real file rather than a copy, so the
+// thing under test is what actually ships.
+func TestMigration032_ImportsOnlyHardwareStoreAndUncategorized(t *testing.T) {
+ db := setupImportTestDB(t)
+ defer func() { _ = db.Close() }()
+
+ seedShoppingBoard(t, db, []card{
+ // Unlisted cards — these render as "Uncategorized" and are imported.
+ {"Broccoli", ""},
+ {"Ibuprofen", ""},
+ {"dog water bowl", ""},
+ // Hardware Store — imported.
+ {"6x8 plywood", "Hardware Store"},
+ {"Wasp traps", "Hardware Store"},
+ // Deliberately left behind.
+ {"Pearl Waffles", "Costco"},
+ {"Pita Bread", "Perennial "},
+ {"Rip stop", "online"},
+ })
+
+ runMigration032(t, db)
+
+ got := userItems(t, db)
+ want := []item{
+ {"6x8 plywood", "Hardware Store"},
+ {"Broccoli", "Uncategorized"},
+ {"Ibuprofen", "Uncategorized"},
+ {"Wasp traps", "Hardware Store"},
+ {"dog water bowl", "Uncategorized"},
+ }
+ assertItems(t, got, want)
+}
+
+// A card whose list has surrounding whitespace must not create a store that
+// differs from the trimmed name only by invisible characters. "Perennial " on
+// the real board was exactly this.
+func TestMigration032_TrimsStoreNames(t *testing.T) {
+ db := setupImportTestDB(t)
+ defer func() { _ = db.Close() }()
+
+ seedShoppingBoard(t, db, []card{
+ {"Trimmed", " Hardware Store "},
+ })
+ runMigration032(t, db)
+
+ got := userItems(t, db)
+ assertItems(t, got, []item{{"Trimmed", "Hardware Store"}})
+}
+
+// Cards on other boards must not be swept in — only the Shopping board.
+func TestMigration032_IgnoresOtherBoards(t *testing.T) {
+ db := setupImportTestDB(t)
+ defer func() { _ = db.Close() }()
+
+ seedBoard(t, db, "camping", "Camping")
+ mustExec(t, db, `INSERT INTO cards (id, name, board_id, list_name) VALUES ('c1','Tent pegs','camping','')`)
+ seedShoppingBoard(t, db, []card{{"Broccoli", ""}})
+
+ runMigration032(t, db)
+
+ got := userItems(t, db)
+ assertItems(t, got, []item{{"Broccoli", "Uncategorized"}})
+}
+
+// Replaying the migration must not duplicate rows.
+func TestMigration032_IsReplaySafe(t *testing.T) {
+ db := setupImportTestDB(t)
+ defer func() { _ = db.Close() }()
+
+ seedShoppingBoard(t, db, []card{{"Broccoli", ""}, {"Wasp traps", "Hardware Store"}})
+
+ runMigration032(t, db)
+ runMigration032(t, db)
+
+ got := userItems(t, db)
+ assertItems(t, got, []item{
+ {"Broccoli", "Uncategorized"},
+ {"Wasp traps", "Hardware Store"},
+ })
+}
+
+// An item the user already added by hand must not gain a duplicate.
+func TestMigration032_SkipsItemsAlreadyPresent(t *testing.T) {
+ db := setupImportTestDB(t)
+ defer func() { _ = db.Close() }()
+
+ mustExec(t, db, `INSERT INTO user_shopping_items (name, store) VALUES ('Wasp traps','Hardware Store')`)
+ seedShoppingBoard(t, db, []card{{"Wasp traps", "Hardware Store"}})
+
+ runMigration032(t, db)
+
+ assertItems(t, userItems(t, db), []item{{"Wasp traps", "Hardware Store"}})
+}
+
+// On a database with no cached Trello cards the migration is a no-op rather
+// than an error — that is the state of any fresh dev database.
+func TestMigration032_NoOpWithNoCards(t *testing.T) {
+ db := setupImportTestDB(t)
+ defer func() { _ = db.Close() }()
+
+ runMigration032(t, db)
+
+ if got := userItems(t, db); len(got) != 0 {
+ t.Errorf("expected no items, got %v", got)
+ }
+}
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+type card struct{ name, listName string }
+type item struct{ Name, Store string }
+
+func setupImportTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "import.db"))
+ if err != nil {
+ t.Fatalf("open test db: %v", err)
+ }
+ db.SetMaxOpenConns(1)
+ mustExec(t, db, `
+ CREATE TABLE boards (id TEXT PRIMARY KEY, name TEXT NOT NULL);
+ CREATE TABLE cards (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ board_id TEXT NOT NULL,
+ list_name TEXT
+ );
+ CREATE TABLE user_shopping_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ store TEXT NOT NULL,
+ checked INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ `)
+ return db
+}
+
+func seedBoard(t *testing.T, db *sql.DB, id, name string) {
+ t.Helper()
+ mustExec(t, db, `INSERT INTO boards (id, name) VALUES (?, ?)`, id, name)
+}
+
+func seedShoppingBoard(t *testing.T, db *sql.DB, cards []card) {
+ t.Helper()
+ seedBoard(t, db, "shop", "Shopping")
+ for i, c := range cards {
+ mustExec(t, db,
+ `INSERT INTO cards (id, name, board_id, list_name) VALUES (?, ?, 'shop', ?)`,
+ "card-"+string(rune('a'+i)), c.name, c.listName)
+ }
+}
+
+func runMigration032(t *testing.T, db *sql.DB) {
+ t.Helper()
+ path := filepath.Join("..", "..", "migrations", "032_import_trello_shopping_items.sql")
+ sqlBytes, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("reading migration: %v", err)
+ }
+ if _, err := db.Exec(string(sqlBytes)); err != nil {
+ t.Fatalf("executing migration: %v", err)
+ }
+}
+
+func userItems(t *testing.T, db *sql.DB) []item {
+ t.Helper()
+ rows, err := db.Query(`SELECT name, store FROM user_shopping_items`)
+ if err != nil {
+ t.Fatalf("querying items: %v", err)
+ }
+ defer func() { _ = rows.Close() }()
+
+ var out []item
+ for rows.Next() {
+ var it item
+ if err := rows.Scan(&it.Name, &it.Store); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ out = append(out, it)
+ }
+ return out
+}
+
+func assertItems(t *testing.T, got, want []item) {
+ t.Helper()
+ sortItems(got)
+ sortItems(want)
+ if len(got) != len(want) {
+ t.Fatalf("got %d items %v, want %d %v", len(got), got, len(want), want)
+ }
+ for i := range got {
+ if got[i] != want[i] {
+ t.Errorf("item %d: got %+v, want %+v", i, got[i], want[i])
+ }
+ }
+}
+
+func sortItems(items []item) {
+ sort.Slice(items, func(i, j int) bool {
+ if items[i].Store != items[j].Store {
+ return items[i].Store < items[j].Store
+ }
+ return items[i].Name < items[j].Name
+ })
+}
+
+func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
+ t.Helper()
+ if _, err := db.Exec(query, args...); err != nil {
+ t.Fatalf("exec %q: %v", query, err)
+ }
+}
diff --git a/migrations/032_import_trello_shopping_items.sql b/migrations/032_import_trello_shopping_items.sql
new file mode 100644
index 0000000..a12a675
--- /dev/null
+++ b/migrations/032_import_trello_shopping_items.sql
@@ -0,0 +1,46 @@
+-- One-time import of Trello Shopping-board cards into user shopping items,
+-- ahead of removing the Trello source from the shopping list entirely.
+--
+-- The Trello "Shopping" board had become a dumping ground: of its 24 cards, 13
+-- had no list at all (rendering as "Uncategorized") and the rest were spread
+-- over Costco, Hardware Store, "Perennial " (note the trailing space, which
+-- made it a separate store from "Perennial") and online.
+--
+-- Only the two groups worth keeping are imported: Hardware Store, and the
+-- unlisted cards. Costco, Perennial and online cards are deliberately left
+-- behind — they stop appearing once the Trello source is removed. Nothing is
+-- destroyed by that: the cards remain in Trello, this only stops doot
+-- displaying them.
+--
+-- Imported rows become ordinary user items, which means they also become fully
+-- editable (rename, move store, delete) — Trello-sourced items never were.
+--
+-- Reads from the local `cards` cache rather than the Trello API. On any
+-- database where that cache is empty (a fresh dev DB, a restore predating a
+-- sync) this is simply a no-op, which is the correct outcome there.
+
+INSERT INTO user_shopping_items (name, store)
+SELECT
+ c.name,
+ CASE
+ WHEN TRIM(COALESCE(c.list_name, '')) = '' THEN 'Uncategorized'
+ ELSE TRIM(c.list_name)
+ END AS store_name
+FROM cards c
+JOIN boards b ON c.board_id = b.id
+WHERE LOWER(b.name) = 'shopping'
+ AND (
+ TRIM(COALESCE(c.list_name, '')) = ''
+ OR LOWER(TRIM(c.list_name)) = 'hardware store'
+ )
+ -- Guard against creating a duplicate of something already added by hand.
+ -- Migrations are tracked by filename so this runs once, but the guard makes
+ -- the statement safe to replay against a partially-imported database.
+ AND NOT EXISTS (
+ SELECT 1 FROM user_shopping_items u
+ WHERE u.name = c.name
+ AND u.store = CASE
+ WHEN TRIM(COALESCE(c.list_name, '')) = '' THEN 'Uncategorized'
+ ELSE TRIM(c.list_name)
+ END
+ );
diff --git a/migrations/README.md b/migrations/README.md
index e849b9c..a98efa2 100644
--- a/migrations/README.md
+++ b/migrations/README.md
@@ -27,10 +27,11 @@ Migration 007 was reserved during development but never implemented. The gap is
## Running Migrations
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 |
## Creating New Migrations
-1. Create file: `migrations/NNN_description.sql` (next number is 014)
+1. Create file: `migrations/NNN_description.sql` (next number is 033)
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/index.html b/web/templates/index.html
index b11b557..f58c5e1 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -189,14 +189,14 @@
<form hx-post="/unified-add"
hx-swap="none"
hx-on::after-request="if(event.detail.successful) { this.reset(); closeActionModal(); htmx.trigger(document.body, 'refresh-tasks'); }">
- <input type="hidden" name="source" value="trello">
+ <input type="hidden" name="source" value="shopping">
<input type="text"
name="title"
placeholder="Item name..."
class="w-full bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50 mb-3"
required>
- <select name="list_id"
- id="shopping-list-select"
+ <select name="store"
+ id="shopping-store-select"
class="w-full bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white mb-3"
required>
<option value="">Loading stores...</option>
@@ -244,9 +244,12 @@
}
}
function loadShoppingLists() {
- var select = document.getElementById('shopping-list-select');
+ // Stores, not Trello lists: the Shopping board is no longer a
+ // shopping source, so an item added to a Trello list would not
+ // have shown up in the list at all.
+ var select = document.getElementById('shopping-store-select');
if (select.options.length <= 1) {
- fetch('/partials/shopping-lists')
+ fetch('/partials/shopping-stores')
.then(function(r) { return r.text(); })
.then(function(html) {
select.innerHTML = html;
@@ -254,7 +257,7 @@
select.selectedIndex = 0;
}
})
- .catch(function(e) { console.error('Failed to load shopping lists:', e); });
+ .catch(function(e) { console.error('Failed to load shopping stores:', e); });
}
}
// Keyboard shortcut: Ctrl+K or Cmd+K
diff --git a/web/templates/partials/shopping-mode-items.html b/web/templates/partials/shopping-mode-items.html
index 5dad772..c49b164 100644
--- a/web/templates/partials/shopping-mode-items.html
+++ b/web/templates/partials/shopping-mode-items.html
@@ -19,8 +19,7 @@
<!-- Source badge -->
<span class="text-xs px-2 py-0.5 rounded flex-shrink-0
- {{if eq .Source "trello"}}bg-blue-900/50 text-blue-300
- {{else if eq .Source "user"}}bg-purple-900/50 text-purple-300
+ {{if eq .Source "user"}}bg-purple-900/50 text-purple-300
{{else}}bg-green-900/50 text-green-300{{end}}">
{{.Source}}
</span>
diff --git a/web/templates/partials/shopping-tab.html b/web/templates/partials/shopping-tab.html
index e68876f..9d49122 100644
--- a/web/templates/partials/shopping-tab.html
+++ b/web/templates/partials/shopping-tab.html
@@ -117,7 +117,7 @@
<li class="flex items-center gap-3 p-3 bg-card bg-card-hover transition-colors rounded-lg border border-white/5">
<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 "trello"}}bg-blue-900/50 text-blue-300{{else if eq .Source "user"}}bg-purple-900/50 text-purple-300{{else}}bg-green-900/50 text-green-300{{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}}">
{{.Source}}
</span>
</li>
@@ -161,7 +161,7 @@
<span class="text-xs px-2 py-0.5 rounded bg-white/10 text-white/50">
{{.Store}}
</span>
- <span class="text-xs px-2 py-0.5 rounded {{if eq .Source "trello"}}bg-blue-900/50 text-blue-300{{else if eq .Source "user"}}bg-purple-900/50 text-purple-300{{else}}bg-green-900/50 text-green-300{{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}}">
{{.Source}}
</span>
</li>