diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-02-01 14:47:50 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-02-01 14:47:50 -1000 |
| commit | f10044eac1997537bcdf7699f5b4284aac16f8e2 (patch) | |
| tree | 12d9ec802eb1fd4e615ab2bbcbb1f3b7f30d0d86 | |
| parent | d310d7d2135b3203ccb55489fe335b855c745630 (diff) | |
Improve shopping mode and flatten nav bar
Shopping mode:
- Click to complete items (deletes user items, hides external items)
- Add print button with compact two-column print layout
- Fix CSRF token for HTMX requests
- Fix input clearing with proper htmx:afterRequest handler
- Remove "Quick Add" store option, require valid store
Navigation:
- Replace dropdown menu with flat nav showing all tabs
- Remove unused dropdown JS
Tests:
- Add TestHandleShoppingModeComplete for user and external items
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | cmd/dashboard/main.go | 1 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 65 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 86 | ||||
| -rw-r--r-- | web/templates/index.html | 78 | ||||
| -rw-r--r-- | web/templates/partials/shopping-mode-items.html | 22 | ||||
| -rw-r--r-- | web/templates/partials/shopping-tab.html | 3 | ||||
| -rw-r--r-- | web/templates/shopping-mode.html | 100 |
7 files changed, 261 insertions, 94 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index f1428ed..077b5b4 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -231,6 +231,7 @@ func main() { // Shopping mode (focused single-store view) r.Get("/shopping/mode/{store}", h.HandleShoppingMode) r.Post("/shopping/mode/{store}/toggle", h.HandleShoppingModeToggle) + r.Post("/shopping/mode/{store}/complete", h.HandleShoppingModeComplete) // Settings r.Get("/settings", h.HandleSettingsPage) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index c384c48..f0f2a19 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -1258,7 +1258,8 @@ func (h *Handler) HandleShoppingQuickAdd(w http.ResponseWriter, r *http.Request) return } if storeName == "" { - storeName = "Quick Add" + JSONError(w, http.StatusBadRequest, "Store is required", nil) + return } if err := h.store.SaveUserShoppingItem(name, storeName); err != nil { @@ -1380,10 +1381,12 @@ func (h *Handler) HandleShoppingMode(w http.ResponseWriter, r *http.Request) { StoreName string Items []models.UnifiedShoppingItem StoreNames []string + CSRFToken string }{ StoreName: storeName, Items: items, StoreNames: storeNames, + CSRFToken: auth.GetCSRFTokenFromContext(ctx), } HTMLResponse(w, h.templates, "shopping-mode.html", data) @@ -1450,6 +1453,64 @@ func (h *Handler) HandleShoppingModeToggle(w http.ResponseWriter, r *http.Reques }{storeName, items}) } +// HandleShoppingModeComplete removes an item from the shopping list +func (h *Handler) HandleShoppingModeComplete(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") + + // Complete (remove) 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.DeleteUserShoppingItem(userID); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to delete item", err) + return + } + case "trello", "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) + 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 alphabetically (checked items already filtered in template) + sort.Slice(items, func(i, j int) bool { + 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) @@ -1535,7 +1596,7 @@ func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingS for _, item := range userItems { storeName := item.Store if storeName == "" { - storeName = "Quick Add" + continue // Skip items without a store } if storeMap[storeName] == nil { diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 96cb911..cd56e32 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "fmt" "html/template" "net/http" "net/http/httptest" @@ -11,6 +12,8 @@ import ( "testing" "time" + "github.com/go-chi/chi/v5" + "task-dashboard/internal/api" "task-dashboard/internal/config" "task-dashboard/internal/models" @@ -698,3 +701,86 @@ func TestHandleCompleteAtom_APIError(t *testing.T) { t.Errorf("Task should NOT be deleted from cache on API error, found %d tasks", len(cachedTasks)) } } + +// TestHandleShoppingModeComplete tests completing (removing) shopping items +func TestHandleShoppingModeComplete(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + tmpl := loadTestTemplates(t) + cfg := &config.Config{TemplateDir: "web/templates"} + + h := &Handler{ + store: db, + templates: tmpl, + config: cfg, + } + + // Add a user shopping item + err := db.SaveUserShoppingItem("Test Item", "TestStore") + if err != nil { + t.Fatalf("Failed to save user shopping item: %v", err) + } + + // Get the item to find its ID + items, err := db.GetUserShoppingItems() + if err != nil { + t.Fatalf("Failed to get user shopping items: %v", err) + } + if len(items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(items)) + } + + itemID := items[0].ID + + t.Run("complete user item deletes it", func(t *testing.T) { + req := httptest.NewRequest("POST", "/shopping/mode/TestStore/complete", nil) + req.Form = map[string][]string{ + "id": {fmt.Sprintf("user-%d", itemID)}, + "source": {"user"}, + } + + // Add chi URL params + rctx := chi.NewRouteContext() + rctx.URLParams.Add("store", "TestStore") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + h.HandleShoppingModeComplete(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Verify item was deleted + remainingItems, _ := db.GetUserShoppingItems() + if len(remainingItems) != 0 { + t.Errorf("Expected 0 items after completion, got %d", len(remainingItems)) + } + }) + + 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"}, + } + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("store", "TestStore") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + h.HandleShoppingModeComplete(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Verify item is marked as checked + checks, _ := db.GetShoppingItemChecks("trello") + if !checks["trello-123"] { + t.Error("Expected trello item to be marked as checked") + } + }) +} diff --git a/web/templates/index.html b/web/templates/index.html index 5322ca6..7e9a38f 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -46,8 +46,7 @@ <!-- Tab Navigation --> <div class="mb-4 sm:mb-6 no-print"> - <nav class="flex space-x-1 bg-panel backdrop-blur-sm rounded-xl p-1 text-shadow-sm"> - <!-- Main Tabs --> + <nav class="flex flex-wrap gap-1 bg-panel backdrop-blur-sm rounded-xl p-1 text-shadow-sm"> <button class="tab-button {{if eq .ActiveTab "timeline"}}tab-button-active{{end}}" hx-get="/tabs/timeline" @@ -57,6 +56,14 @@ 🗓️ Timeline </button> <button + class="tab-button {{if eq .ActiveTab "tasks"}}tab-button-active{{end}}" + hx-get="/tabs/tasks" + hx-target="#tab-content" + hx-push-url="?tab=tasks" + onclick="setActiveTab(this)"> + ✓ Tasks + </button> + <button class="tab-button {{if eq .ActiveTab "shopping"}}tab-button-active{{end}}" hx-get="/tabs/shopping" hx-target="#tab-content" @@ -64,44 +71,25 @@ onclick="setActiveTab(this)"> 🛒 Shopping </button> + <button + class="tab-button {{if eq .ActiveTab "meals"}}tab-button-active{{end}}" + hx-get="/tabs/meals" + hx-target="#tab-content" + hx-push-url="?tab=meals" + onclick="setActiveTab(this)"> + 🍽️ Meals + </button> + <button + class="tab-button {{if eq .ActiveTab "planning"}}tab-button-active{{end}}" + hx-get="/tabs/planning" + hx-target="#tab-content" + hx-push-url="?tab=planning" + onclick="setActiveTab(this)"> + 📋 Planning + </button> <a href="/conditions" class="tab-button"> 🌋 Conditions </a> - - <!-- Details Dropdown --> - <div class="relative" id="details-dropdown"> - <button - class="tab-button {{if or (eq .ActiveTab "tasks") (eq .ActiveTab "planning") (eq .ActiveTab "meals")}}tab-button-active{{end}}" - onclick="toggleDetailsDropdown(event)"> - 📁 Details ▾ - </button> - <div id="details-menu" class="hidden absolute top-full left-0 mt-1 bg-black/90 backdrop-blur-md rounded-lg p-1 min-w-[140px] z-[100] border border-white/20 shadow-lg"> - <button - class="w-full text-left px-3 py-2 rounded text-sm text-white/80 hover:bg-white/20 hover:text-white transition-colors {{if eq .ActiveTab "tasks"}}bg-white/20 text-white{{end}}" - hx-get="/tabs/tasks" - hx-target="#tab-content" - hx-push-url="?tab=tasks" - onclick="setActiveTab(this); closeDetailsDropdown();"> - ✓ Tasks - </button> - <button - class="w-full text-left px-3 py-2 rounded text-sm text-white/80 hover:bg-white/20 hover:text-white transition-colors {{if eq .ActiveTab "planning"}}bg-white/20 text-white{{end}}" - hx-get="/tabs/planning" - hx-target="#tab-content" - hx-push-url="?tab=planning" - onclick="setActiveTab(this); closeDetailsDropdown();"> - 📋 Planning - </button> - <button - class="w-full text-left px-3 py-2 rounded text-sm text-white/80 hover:bg-white/20 hover:text-white transition-colors {{if eq .ActiveTab "meals"}}bg-white/20 text-white{{end}}" - hx-get="/tabs/meals" - hx-target="#tab-content" - hx-push-url="?tab=meals" - onclick="setActiveTab(this); closeDetailsDropdown();"> - 🍽️ Meals - </button> - </div> - </div> </nav> </div> @@ -297,22 +285,6 @@ function closeTaskModal() { document.getElementById('task-edit-modal').classList.add('hidden'); } - // Details dropdown functions - function toggleDetailsDropdown(e) { - e.stopPropagation(); - var menu = document.getElementById('details-menu'); - menu.classList.toggle('hidden'); - } - function closeDetailsDropdown() { - document.getElementById('details-menu').classList.add('hidden'); - } - // Close dropdown when clicking outside - document.addEventListener('click', function(e) { - var dropdown = document.getElementById('details-dropdown'); - if (dropdown && !dropdown.contains(e.target)) { - closeDetailsDropdown(); - } - }); </script> <!-- Task Edit Modal --> diff --git a/web/templates/partials/shopping-mode-items.html b/web/templates/partials/shopping-mode-items.html index fdf0674..5dad772 100644 --- a/web/templates/partials/shopping-mode-items.html +++ b/web/templates/partials/shopping-mode-items.html @@ -2,28 +2,19 @@ <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}}}' + {{if not .Checked}} + <div class="flex items-center gap-3 p-4 bg-white/5 rounded-xl border border-white/10 cursor-pointer" + hx-post="/shopping/mode/{{$.StoreName}}/complete" + hx-vals='{"id":"{{.ID}}","source":"{{.Source}}"}' 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> + <div class="w-6 h-6 rounded-full border-2 border-white/30 flex items-center justify-center flex-shrink-0"></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}} + <span class="text-white">{{.Name}}</span>{{if .Quantity}} <span class="text-sm text-white/50 item-qty">({{.Quantity}})</span>{{end}} </div> <!-- Source badge --> @@ -35,6 +26,7 @@ </span> </div> {{end}} + {{end}} {{else}} <div class="text-center py-16 text-white/50"> <p class="text-lg mb-2">No items</p> diff --git a/web/templates/partials/shopping-tab.html b/web/templates/partials/shopping-tab.html index e5fa3e6..0b22c15 100644 --- a/web/templates/partials/shopping-tab.html +++ b/web/templates/partials/shopping-tab.html @@ -33,8 +33,7 @@ <input type="text" name="name" placeholder="Add item..." class="flex-1 bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-white/30 text-sm" required> - <select name="store" class="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="Quick Add">Quick Add</option> + <select name="store" class="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" required> {{range .Stores}} <option value="{{.Name}}">{{.Name}}</option> {{end}} diff --git a/web/templates/shopping-mode.html b/web/templates/shopping-mode.html index 88d8561..b2f129a 100644 --- a/web/templates/shopping-mode.html +++ b/web/templates/shopping-mode.html @@ -14,20 +14,6 @@ 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; @@ -35,11 +21,62 @@ .store-switcher::-webkit-scrollbar { display: none; } + .flash-success { + background: rgba(34, 197, 94, 0.2); + border-radius: 8px; + } + @media print { + * { + text-shadow: none !important; + } + body { + background: white !important; + color: black !important; + font-size: 10px !important; + line-height: 1.2 !important; + } + .no-print { + display: none !important; + } + main { + padding: 0 !important; + } + #shopping-items > div { + display: grid !important; + grid-template-columns: 1fr 1fr !important; + gap: 0 12px !important; + } + #shopping-items > div > div { + background: none !important; + border: none !important; + border-radius: 0 !important; + padding: 1px 0 !important; + color: black !important; + gap: 3px !important; + } + #shopping-items span { + color: black !important; + font-size: 10px !important; + } + #shopping-items .item-qty { + font-size: 8px !important; + color: #666 !important; + } + #shopping-items .rounded-full { + width: 8px !important; + height: 8px !important; + border-width: 1px !important; + border-color: #888 !important; + } + #shopping-items .text-xs { + display: none !important; + } + } </style> </head> -<body class="text-white"> +<body class="text-white" hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'> <!-- Header --> - <header class="sticky top-0 z-50 bg-black/40 backdrop-blur-lg border-b border-white/10"> + <header class="sticky top-0 z-50 bg-black/40 backdrop-blur-lg border-b border-white/10 no-print"> <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"> @@ -47,7 +84,11 @@ </svg> </a> <h1 class="text-lg font-semibold">{{.StoreName}}</h1> - <div class="w-10"></div> + <button onclick="window.print()" class="text-white/70 hover:text-white p-2 -mr-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="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"></path> + </svg> + </button> </div> <!-- Store Switcher --> @@ -64,18 +105,22 @@ {{end}} </header> + <!-- Print Header --> + <div class="hidden print:block mb-1"> + <h1 class="text-sm font-semibold text-black">{{.StoreName}}</h1> + </div> + <!-- 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" + <div class="fixed bottom-0 left-0 right-0 bg-black/60 backdrop-blur-lg border-t border-white/10 p-4 no-print"> + <form id="quick-add-form" hx-post="/shopping/add" hx-target="#shopping-items" hx-swap="innerHTML" - hx-on::after-request="if(event.detail.successful) this.reset();" - class="flex gap-2"> + class="flex gap-2 transition-all"> <input type="hidden" name="store" value="{{.StoreName}}"> <input type="hidden" name="mode" value="shopping-mode"> <input type="text" name="name" placeholder="Add item..." @@ -88,6 +133,17 @@ </div> <!-- Spacer for fixed bottom form --> - <div class="h-24"></div> + <div class="h-24 no-print"></div> + + <script> + document.body.addEventListener('htmx:afterRequest', function(evt) { + if (evt.detail.elt.id === 'quick-add-form' && evt.detail.successful) { + evt.detail.elt.reset(); + evt.detail.elt.querySelector('input[name=name]').focus(); + evt.detail.elt.classList.add('flash-success'); + setTimeout(function() { evt.detail.elt.classList.remove('flash-success'); }, 300); + } + }); + </script> </body> </html> |
