summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/handlers/handlers.go28
-rw-r--r--internal/handlers/shopping_template_test.go127
-rw-r--r--web/templates/partials/shopping-tab.html9
3 files changed, 162 insertions, 2 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index e70650d..c5df00d 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -1251,6 +1251,34 @@ func templateFuncMap() template.FuncMap {
return template.FuncMap{
"subtract": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b },
+ // slug turns a free-text name into an id-safe fragment, so a template
+ // can give a section a stable anchor and target it by selector. Store
+ // names are user data ("Trader Joe's", "Hardware Store") and go
+ // straight into element ids, so anything outside [a-z0-9-] has to go.
+ "slug": func(s string) string {
+ var b strings.Builder
+ lastDash := true // leading dashes are suppressed
+ for _, r := range strings.ToLower(s) {
+ switch {
+ case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
+ b.WriteRune(r)
+ lastDash = false
+ default:
+ // Collapse any run of separators/punctuation into one dash.
+ if !lastDash {
+ b.WriteByte('-')
+ lastDash = true
+ }
+ }
+ }
+ out := strings.Trim(b.String(), "-")
+ if out == "" {
+ // An empty id would make every such section share the same
+ // (invalid) anchor, so give it something addressable.
+ return "unnamed"
+ }
+ return out
+ },
// multiDayLabel returns the "(starts/ends HH:MM)" suffix for a
// multi-day calendar event's row on the day it's rendered, or ""
// for a normal item or a "spans" (pass-through) day, which shows
diff --git a/internal/handlers/shopping_template_test.go b/internal/handlers/shopping_template_test.go
new file mode 100644
index 0000000..06f63b3
--- /dev/null
+++ b/internal/handlers/shopping_template_test.go
@@ -0,0 +1,127 @@
+package handlers
+
+import (
+ "bytes"
+ "html/template"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "task-dashboard/internal/models"
+)
+
+// renderShoppingTab renders the real shopping-tab template with the real func
+// map, so these assertions cover what actually ships rather than a stand-in.
+func renderShoppingTab(t *testing.T, data any) string {
+ t.Helper()
+ dir := filepath.Join("..", "..", "web", "templates")
+
+ tmpl, err := template.New("").Funcs(templateFuncMap()).ParseGlob(filepath.Join(dir, "*.html"))
+ if err != nil {
+ t.Fatalf("parsing top-level templates: %v", err)
+ }
+ if _, err := tmpl.ParseGlob(filepath.Join(dir, "partials", "*.html")); err != nil {
+ t.Fatalf("parsing partials: %v", err)
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.ExecuteTemplate(&buf, "shopping-tab", data); err != nil {
+ t.Fatalf("executing shopping-tab: %v", err)
+ }
+ return buf.String()
+}
+
+func twoStoreTabData() any {
+ return struct {
+ Stores []models.ShoppingStore
+ Grouped bool
+ }{
+ Stores: []models.ShoppingStore{
+ {Name: "Costco", Categories: []models.ShoppingCategory{
+ {Name: "", Items: []models.UnifiedShoppingItem{
+ {ID: "user-1", Name: "Milk", Store: "Costco", Source: "user"},
+ }},
+ }},
+ {Name: "Hardware Store", Categories: []models.ShoppingCategory{
+ {Name: "", Items: []models.UnifiedShoppingItem{
+ {ID: "user-2", Name: "Screws", Store: "Hardware Store", Source: "user"},
+ }},
+ }},
+ },
+ Grouped: true,
+ }
+}
+
+// The inline per-store add form used to submit with a bare
+// hx-swap="innerHTML". Because that form lives *inside* the swap target
+// (#shopping-items-list), submitting it destroys the form mid-request and the
+// page ends up scrolled to the bottom instead of staying where the user was.
+// The quick-add form at the top already pinned this with an explicit
+// scroll: modifier; the per-store forms did not.
+func TestShoppingTab_InlineAddScrollsToItsOwnStore(t *testing.T) {
+ html := renderShoppingTab(t, twoStoreTabData())
+
+ for _, want := range []string{
+ `id="store-costco"`,
+ `id="store-hardware-store"`,
+ `scroll:#store-costco:top`,
+ `scroll:#store-hardware-store:top`,
+ } {
+ if !strings.Contains(html, want) {
+ t.Errorf("rendered shopping tab is missing %q", want)
+ }
+ }
+}
+
+// Every inline add form must carry a scroll target. A form without one is the
+// bug, and it is easy to reintroduce by adding a new store section by copy.
+func TestShoppingTab_EveryInlineAddFormHasAScrollTarget(t *testing.T) {
+ html := renderShoppingTab(t, twoStoreTabData())
+
+ // Count the class attribute specifically — a bare "inline-add-form" also
+ // matches the string literal in the page's own JS handler.
+ forms := strings.Count(html, `class="inline-add-form`)
+ scrolls := strings.Count(html, "scroll:#store-")
+ if forms == 0 {
+ t.Fatal("expected at least one inline add form in the grouped view")
+ }
+ if forms != scrolls {
+ t.Errorf("found %d inline add forms but %d scroll targets; every form needs one",
+ forms, scrolls)
+ }
+}
+
+func TestSlug(t *testing.T) {
+ slug, ok := templateFuncMap()["slug"].(func(string) string)
+ if !ok {
+ t.Fatal("templateFuncMap has no slug function of type func(string) string")
+ }
+
+ cases := []struct{ in, want string }{
+ {"Costco", "costco"},
+ {"Hardware Store", "hardware-store"},
+ {"Trader Joe's", "trader-joe-s"},
+ {" Spaced Out ", "spaced-out"},
+ {"Multiple---Dashes", "multiple-dashes"},
+ // Non-ASCII is treated as a separator rather than transliterated. That
+ // keeps the id valid without pulling in a transliteration table; the
+ // result only has to be stable and unique, not pretty.
+ {"Ünïcode Störe", "n-code-st-re"},
+ {"", "unnamed"},
+ {"!!!", "unnamed"},
+ }
+ for _, c := range cases {
+ if got := slug(c.in); got != c.want {
+ t.Errorf("slug(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+// Two stores whose names differ only by punctuation must not collide into the
+// same element id, or the scroll target becomes ambiguous.
+func TestSlug_DistinguishesSimilarNames(t *testing.T) {
+ slug := templateFuncMap()["slug"].(func(string) string)
+ if slug("Store A") == slug("Store B") {
+ t.Error("distinct store names produced the same slug")
+ }
+}
diff --git a/web/templates/partials/shopping-tab.html b/web/templates/partials/shopping-tab.html
index 06d2b56..e68876f 100644
--- a/web/templates/partials/shopping-tab.html
+++ b/web/templates/partials/shopping-tab.html
@@ -97,7 +97,7 @@
{{if .Grouped}}
<!-- Grouped View: Items by Store -->
{{range .Stores}}
- <section class="bg-panel backdrop-blur-sm rounded-xl p-4 sm:p-5 mb-6">
+ <section id="store-{{slug .Name}}" class="bg-panel backdrop-blur-sm rounded-xl p-4 sm:p-5 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-medium text-white">{{.Name}}</h2>
<a href="/shopping/mode/{{.Name}}"
@@ -127,10 +127,15 @@
</div>
{{end}}
<!-- Inline Add Item -->
+ <!-- scroll: is not optional here. This form sits *inside* the swap
+ target, so submitting it replaces the form mid-request; without an
+ explicit scroll target the page lands at the bottom instead of
+ staying on the store you were adding to. Anchored to this store's
+ own section, which is re-rendered with the same id. -->
<form class="inline-add-form mt-3 flex gap-2"
hx-post="/shopping/add"
hx-target="#shopping-items-list"
- hx-swap="innerHTML">
+ hx-swap="innerHTML scroll:#store-{{slug .Name}}:top">
<input type="hidden" name="store" value="{{.Name}}">
<input type="hidden" name="grouped" value="true">
<input type="text" name="name" placeholder="Add to {{.Name}}..."