summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-02-01 14:47:50 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-02-01 14:47:50 -1000
commitf10044eac1997537bcdf7699f5b4284aac16f8e2 (patch)
tree12d9ec802eb1fd4e615ab2bbcbb1f3b7f30d0d86 /internal
parentd310d7d2135b3203ccb55489fe335b855c745630 (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>
Diffstat (limited to 'internal')
-rw-r--r--internal/handlers/handlers.go65
-rw-r--r--internal/handlers/handlers_test.go86
2 files changed, 149 insertions, 2 deletions
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")
+ }
+ })
+}