diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-01-26 19:00:36 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-01-26 19:00:36 -1000 |
| commit | 70e6e51b6781a3986c51e3496b81c88665286872 (patch) | |
| tree | 091d0eb9daa08f4e892486451a154a67fb8a3cfe | |
| parent | bbf12fc441ca36c423e865107d34df178e3d26de (diff) | |
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 <noreply@anthropic.com>
| -rw-r--r-- | cmd/dashboard/main.go | 4 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 152 | ||||
| -rw-r--r-- | web/templates/partials/shopping-mode-items.html | 45 | ||||
| -rw-r--r-- | web/templates/partials/shopping-tab.html | 11 | ||||
| -rw-r--r-- | web/templates/shopping-mode.html | 93 |
5 files changed, 297 insertions, 8 deletions
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"}} +<div class="space-y-2"> + {{if .Items}} + {{range .Items}} + <div class="item-row flex items-center gap-3 p-4 bg-white/5 rounded-xl border border-white/10 cursor-pointer {{if .Checked}}item-checked{{end}}" + hx-post="/shopping/mode/{{$.StoreName}}/toggle" + hx-vals='{"id":"{{.ID}}","source":"{{.Source}}","checked":{{if .Checked}}false{{else}}true{{end}}}' + hx-target="#shopping-items" + hx-swap="innerHTML"> + + <!-- Checkbox visual --> + <div class="w-6 h-6 rounded-full border-2 flex items-center justify-center flex-shrink-0 + {{if .Checked}}bg-green-500/30 border-green-500{{else}}border-white/30{{end}}"> + {{if .Checked}} + <svg class="w-4 h-4 text-green-400" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path> + </svg> + {{end}} + </div> + + <!-- Item content --> + <div class="flex-1 min-w-0"> + <span class="item-name text-white block">{{.Name}}</span> + {{if .Quantity}} + <span class="text-sm text-white/50">{{.Quantity}}</span> + {{end}} + </div> + + <!-- 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 + {{else}}bg-green-900/50 text-green-300{{end}}"> + {{.Source}} + </span> + </div> + {{end}} + {{else}} + <div class="text-center py-16 text-white/50"> + <p class="text-lg mb-2">No items</p> + <p class="text-sm">Add items using the form below</p> + </div> + {{end}} +</div> +{{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}} <section class="bg-panel backdrop-blur-sm rounded-xl p-4 sm:p-5"> - <h2 class="text-xl font-medium mb-4 text-white">{{.Name}}</h2> + <div class="flex items-center justify-between mb-4"> + <h2 class="text-xl font-medium text-white">{{.Name}}</h2> + <a href="/shopping/mode/{{.Name}}" + class="px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-sm text-white/70 hover:text-white transition-colors flex items-center gap-1.5"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path> + </svg> + Shop + </a> + </div> {{range .Categories}} <div class="mb-4 last:mb-0"> {{if .Name}}<h3 class="text-sm text-white/60 mb-2 uppercase tracking-wide">{{.Name}}</h3>{{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 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> + <title>{{.StoreName}} - Shopping</title> + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> + <link rel="stylesheet" href="/static/css/output.css"> + <script src="/static/js/htmx.min.js"></script> + <style> + body { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); + min-height: 100vh; + margin: 0; + padding: 0; + } + .item-row { + transition: all 0.2s ease; + } + .item-row:active { + transform: scale(0.98); + background: rgba(255,255,255,0.15); + } + .item-checked { + opacity: 0.5; + } + .item-checked .item-name { + text-decoration: line-through; + color: rgba(255,255,255,0.4); + } + .store-switcher { + scrollbar-width: none; + -ms-overflow-style: none; + } + .store-switcher::-webkit-scrollbar { + display: none; + } + </style> +</head> +<body class="text-white"> + <!-- Header --> + <header class="sticky top-0 z-50 bg-black/40 backdrop-blur-lg border-b border-white/10"> + <div class="flex items-center justify-between px-4 py-3"> + <a href="/?tab=shopping" class="text-white/70 hover:text-white p-2 -ml-2"> + <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path> + </svg> + </a> + <h1 class="text-lg font-semibold">{{.StoreName}}</h1> + <div class="w-10"></div> + </div> + + <!-- Store Switcher --> + {{if gt (len .StoreNames) 1}} + <div class="store-switcher flex gap-2 px-4 pb-3 overflow-x-auto"> + {{range .StoreNames}} + <a href="/shopping/mode/{{.}}" + class="px-3 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors + {{if eq . $.StoreName}}bg-white/20 text-white{{else}}bg-white/5 text-white/60 hover:bg-white/10{{end}}"> + {{.}} + </a> + {{end}} + </div> + {{end}} + </header> + + <!-- Items List --> + <main class="p-4" id="shopping-items"> + {{template "shopping-mode-items" .}} + </main> + + <!-- Quick Add Form (Fixed at Bottom) --> + <div class="fixed bottom-0 left-0 right-0 bg-black/60 backdrop-blur-lg border-t border-white/10 p-4"> + <form hx-post="/shopping/add" + hx-target="#shopping-items" + hx-swap="innerHTML" + hx-on::after-request="this.reset()" + class="flex gap-2"> + <input type="hidden" name="store" value="{{.StoreName}}"> + <input type="hidden" name="mode" value="shopping-mode"> + <input type="text" name="name" placeholder="Add item..." + class="flex-1 bg-white/10 border border-white/20 rounded-lg px-4 py-3 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-white/30" + required autocomplete="off"> + <button type="submit" class="bg-white/20 hover:bg-white/30 text-white px-5 py-3 rounded-lg font-medium transition-colors"> + Add + </button> + </form> + </div> + + <!-- Spacer for fixed bottom form --> + <div class="h-24"></div> +</body> +</html> |
