summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-26 19:00:36 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-26 19:00:36 -1000
commit70e6e51b6781a3986c51e3496b81c88665286872 (patch)
tree091d0eb9daa08f4e892486451a154a67fb8a3cfe
parentbbf12fc441ca36c423e865107d34df178e3d26de (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.go4
-rw-r--r--internal/handlers/handlers.go152
-rw-r--r--web/templates/partials/shopping-mode-items.html45
-rw-r--r--web/templates/partials/shopping-tab.html11
-rw-r--r--web/templates/shopping-mode.html93
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>