From 70e6e51b6781a3986c51e3496b81c88665286872 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 26 Jan 2026 19:00:36 -1000 Subject: Add shopping mode for focused single-store shopping (#34) - Full-screen view for one store at a time - Tap items to toggle completion - Completed items greyed and sorted to bottom - Quick-add form at bottom of screen - Store switcher pills for easy navigation - "Shop" button on each store in shopping tab Co-Authored-By: Claude Opus 4.5 --- cmd/dashboard/main.go | 4 + internal/handlers/handlers.go | 152 ++++++++++++++++++++++-- web/templates/partials/shopping-mode-items.html | 45 +++++++ web/templates/partials/shopping-tab.html | 11 +- web/templates/shopping-mode.html | 93 +++++++++++++++ 5 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 web/templates/partials/shopping-mode-items.html create mode 100644 web/templates/shopping-mode.html diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 6c735d8..a02ece4 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -182,6 +182,10 @@ func main() { // Shopping quick-add r.Post("/shopping/add", h.HandleShoppingQuickAdd) r.Post("/shopping/toggle", h.HandleShoppingToggle) + + // Shopping mode (focused single-store view) + r.Get("/shopping/mode/{store}", h.HandleShoppingMode) + r.Post("/shopping/mode/{store}/toggle", h.HandleShoppingModeToggle) }) // Start server diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index a1a12e7..115d903 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -6,12 +6,15 @@ import ( "html/template" "log" "net/http" + "net/url" "path/filepath" "sort" "strings" "sync" "time" + "github.com/go-chi/chi/v5" + "task-dashboard/internal/api" "task-dashboard/internal/auth" "task-dashboard/internal/config" @@ -1147,25 +1150,51 @@ func (h *Handler) HandleShoppingQuickAdd(w http.ResponseWriter, r *http.Request) } name := strings.TrimSpace(r.FormValue("name")) - store := strings.TrimSpace(r.FormValue("store")) + storeName := strings.TrimSpace(r.FormValue("store")) + mode := r.FormValue("mode") // "shopping-mode" if from shopping mode page if name == "" { JSONError(w, http.StatusBadRequest, "Name is required", nil) return } - if store == "" { - store = "Quick Add" + if storeName == "" { + storeName = "Quick Add" } - if err := h.store.SaveUserShoppingItem(name, store); err != nil { + if err := h.store.SaveUserShoppingItem(name, storeName); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to save item", err) return } - // Return refreshed shopping tab ctx := r.Context() - stores := h.aggregateShoppingLists(ctx) - HTMLResponse(w, h.templates, "shopping-tab", struct{ Stores []models.ShoppingStore }{stores}) + allStores := h.aggregateShoppingLists(ctx) + + // If called from shopping mode, return just the items for that store + if mode == "shopping-mode" { + var items []models.UnifiedShoppingItem + for _, store := range allStores { + if strings.EqualFold(store.Name, storeName) { + for _, cat := range store.Categories { + items = append(items, cat.Items...) + } + } + } + // Sort: unchecked first, then checked + sort.Slice(items, func(i, j int) bool { + if items[i].Checked != items[j].Checked { + return !items[i].Checked + } + return items[i].Name < items[j].Name + }) + HTMLResponse(w, h.templates, "shopping-mode-items", struct { + StoreName string + Items []models.UnifiedShoppingItem + }{storeName, items}) + return + } + + // Return refreshed shopping tab + HTMLResponse(w, h.templates, "shopping-tab", struct{ Stores []models.ShoppingStore }{allStores}) } // HandleShoppingToggle toggles a shopping item's checked state @@ -1206,6 +1235,115 @@ func (h *Handler) HandleShoppingToggle(w http.ResponseWriter, r *http.Request) { HTMLResponse(w, h.templates, "shopping-tab", struct{ Stores []models.ShoppingStore }{stores}) } +// HandleShoppingMode renders the focused shopping mode for a single store +func (h *Handler) HandleShoppingMode(w http.ResponseWriter, r *http.Request) { + storeName := chi.URLParam(r, "store") + if storeName == "" { + http.Redirect(w, r, "/?tab=shopping", http.StatusFound) + return + } + + // URL decode the store name + storeName, _ = url.QueryUnescape(storeName) + + ctx := r.Context() + allStores := h.aggregateShoppingLists(ctx) + + // Find the requested store + var items []models.UnifiedShoppingItem + var storeNames []string + for _, store := range allStores { + storeNames = append(storeNames, store.Name) + if strings.EqualFold(store.Name, storeName) { + // Flatten categories into single item list + for _, cat := range store.Categories { + items = append(items, cat.Items...) + } + } + } + + // Sort: unchecked first, then checked (both alphabetically within their group) + sort.Slice(items, func(i, j int) bool { + if items[i].Checked != items[j].Checked { + return !items[i].Checked // unchecked items first + } + return items[i].Name < items[j].Name + }) + + data := struct { + StoreName string + Items []models.UnifiedShoppingItem + StoreNames []string + }{ + StoreName: storeName, + Items: items, + StoreNames: storeNames, + } + + HTMLResponse(w, h.templates, "shopping-mode", data) +} + +// HandleShoppingModeToggle toggles an item in shopping mode and returns updated list +func (h *Handler) HandleShoppingModeToggle(w http.ResponseWriter, r *http.Request) { + storeName := chi.URLParam(r, "store") + if err := r.ParseForm(); err != nil { + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) + return + } + + id := r.FormValue("id") + source := r.FormValue("source") + checked := r.FormValue("checked") == "true" + + // Toggle the item + switch source { + case "user": + var userID int64 + if _, err := fmt.Sscanf(id, "user-%d", &userID); err != nil { + JSONError(w, http.StatusBadRequest, "Invalid user item ID", err) + return + } + if err := h.store.ToggleUserShoppingItem(userID, checked); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err) + return + } + case "trello", "plantoeat": + if err := h.store.SetShoppingItemChecked(source, id, checked); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err) + return + } + } + + // URL decode the store name + storeName, _ = url.QueryUnescape(storeName) + + // Return updated item list partial + ctx := r.Context() + allStores := h.aggregateShoppingLists(ctx) + + var items []models.UnifiedShoppingItem + for _, store := range allStores { + if strings.EqualFold(store.Name, storeName) { + for _, cat := range store.Categories { + items = append(items, cat.Items...) + } + } + } + + // Sort: unchecked first, then checked + sort.Slice(items, func(i, j int) bool { + if items[i].Checked != items[j].Checked { + return !items[i].Checked + } + return items[i].Name < items[j].Name + }) + + HTMLResponse(w, h.templates, "shopping-mode-items", struct { + StoreName string + Items []models.UnifiedShoppingItem + }{storeName, items}) +} + // aggregateShoppingLists combines Trello, PlanToEat, and user shopping items by store func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingStore { storeMap := make(map[string]map[string][]models.UnifiedShoppingItem) diff --git a/web/templates/partials/shopping-mode-items.html b/web/templates/partials/shopping-mode-items.html new file mode 100644 index 0000000..fdf0674 --- /dev/null +++ b/web/templates/partials/shopping-mode-items.html @@ -0,0 +1,45 @@ +{{define "shopping-mode-items"}} +
+ {{if .Items}} + {{range .Items}} +
+ + +
+ {{if .Checked}} + + + + {{end}} +
+ + +
+ {{.Name}} + {{if .Quantity}} + {{.Quantity}} + {{end}} +
+ + + + {{.Source}} + +
+ {{end}} + {{else}} +
+

No items

+

Add items using the form below

+
+ {{end}} +
+{{end}} diff --git a/web/templates/partials/shopping-tab.html b/web/templates/partials/shopping-tab.html index f247f3d..4d0ac02 100644 --- a/web/templates/partials/shopping-tab.html +++ b/web/templates/partials/shopping-tab.html @@ -29,7 +29,16 @@ {{if .Stores}} {{range .Stores}}
-

{{.Name}}

+
+

{{.Name}}

+ + + + + Shop + +
{{range .Categories}}
{{if .Name}}

{{.Name}}

{{end}} diff --git a/web/templates/shopping-mode.html b/web/templates/shopping-mode.html new file mode 100644 index 0000000..9e21ac6 --- /dev/null +++ b/web/templates/shopping-mode.html @@ -0,0 +1,93 @@ + + + + + + {{.StoreName}} - Shopping + + + + + + + +
+
+ + + + + +

{{.StoreName}}

+
+
+ + + {{if gt (len .StoreNames) 1}} +
+ {{range .StoreNames}} + + {{.}} + + {{end}} +
+ {{end}} +
+ + +
+ {{template "shopping-mode-items" .}} +
+ + +
+
+ + + + +
+
+ + +
+ + -- cgit v1.2.3