fix(series): Date Added respects the direction toggle; bump bundle stamp

Two follow-ups from live testing:

- "recent" is BOTH a base toolbar key and a full registry key (where it
  means desc). ResolveSeriesSort's full-key passthrough ran first, so
  sort=recent&dir=asc silently resolved back to desc — the direction
  chevron flipped but Date Added could never sort ascending. "recent"
  now composes with the direction before the passthrough, unknown bases
  fall to the default entirely (a hostile dir can't ride an unknown
  sort), and the composition table is unit-pinned plus an HTTP-level
  row in the series filter test.

- The header search "still broken" report was a stale-cache artifact:
  the fixed code shipped but main.js kept its ?v=7 stamp, so browsers
  with a cached copy never refetched (fresh-profile e2e passed while
  the user's browser didn't — heuristically cached module). Stamp bumped
  to ?v=8 in header + docs; the e2e suite re-verified against the
  rebuilt server.
This commit is contained in:
John O'Keefe committed 2026-10-27 22:55:11 -04:00
1 parent ffbcc53083
commit fd9292e6f2
7 files changed
+56 -5

No files matched your search

@@ -176,6 +176,9 @@ func TestSeriesSortAndFilter(t *testing.T) {
{"sort=books&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // books desc default, flipped {"sort=books&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // books desc default, flipped
{"sort=progress&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // progress desc default, flipped {"sort=progress&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // progress desc default, flipped
{"sort=author", []string{"Mango", "Apple", "Zebra"}}, // author asc default {"sort=author", []string{"Mango", "Apple", "Zebra"}}, // author asc default
// THE toggle regression (2026-10-27): "recent" is both a base
// key and a registry key — dir=asc used to be swallowed.
{"sort=recent&dir=asc", []string{"Apple", "Mango", "Zebra"}},
} }
for _, tc := range cases { for _, tc := range cases {
got := renderedNames("&" + tc.query) got := renderedNames("&" + tc.query)
+37
View File
@@ -320,3 +320,40 @@ func TestSeriesNumberFloatOrderingValues(t *testing.T) {
t.Fatal("1 must sort before 1.5") t.Fatal("1 must sort before 1.5")
} }
} }
// ResolveSeriesSort composes the toolbar's base key + direction into
// the registry key the SQL cascade implements. "recent" is both a base
// key AND a full registry key (meaning desc) — the composition must
// run BEFORE the full-key passthrough, or Date Added can never sort
// ascending (2026-10-27 regression the direction toggle exposed).
func TestResolveSeriesSortComposition(t *testing.T) {
cases := []struct {
sort, dir string
wantKey, wantD string
}{
// THE regression: Date Added + toggle up.
{"recent", "asc", "recent_asc", "asc"},
{"recent", "", "recent", "desc"},
{"recent", "desc", "recent", "desc"},
{"recent", "bogus", "recent", "desc"},
// Full keys (back-compat URLs / JSON API) win as-is.
{"name_desc", "", "name_desc", "desc"},
{"name_asc", "desc", "name_asc", "asc"},
// Base keys compose; default direction applies when dir absent.
{"name", "", "name_asc", "asc"},
{"name", "desc", "name_desc", "desc"},
{"books", "", "books_desc", "desc"},
{"books", "asc", "books_asc", "asc"},
{"last_read", "asc", "last_read_asc", "asc"},
// Unknown/empty → default.
{"", "", "recent", "desc"},
{"bogus", "asc", "recent", "desc"},
}
for _, tc := range cases {
key, dir := ResolveSeriesSort(tc.sort, tc.dir)
if key != tc.wantKey || dir != tc.wantD {
t.Errorf("ResolveSeriesSort(%q, %q) = (%q, %q), want (%q, %q)",
tc.sort, tc.dir, key, dir, tc.wantKey, tc.wantD)
}
}
}
+12 -1
View File
@@ -91,6 +91,15 @@ func ResolveSeriesSort(sort, dir string) (string, string) {
if dir != "asc" && dir != "desc" { if dir != "asc" && dir != "desc" {
dir = "" dir = ""
} }
// "recent" is BOTH a base toolbar key and a registry key (it means
// desc). Handle it before the full-key check or dir=asc is silently
// swallowed and the direction toggle can never flip Date Added.
if sort == "recent" {
if dir == "asc" {
return "recent_asc", "asc"
}
return "recent", "desc"
}
// Full registry key straight from the URL wins as-is. // Full registry key straight from the URL wins as-is.
if seriesSortKeys[sort] { if seriesSortKeys[sort] {
return sort, seriesDirOf(sort) return sort, seriesDirOf(sort)
@@ -101,7 +110,9 @@ func ResolveSeriesSort(sort, dir string) (string, string) {
} }
defaultDir, known := seriesBaseSorts[base] defaultDir, known := seriesBaseSorts[base]
if !known { if !known {
base, defaultDir = "recent", "desc" // Unknown base: the whole thing falls to the default — an
// unhandled sort must not sneak a direction past the registry.
return "recent", "desc"
} }
if dir == "" { if dir == "" {
dir = defaultDir dir = defaultDir
+1 -1
View File
@@ -72,7 +72,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
</main> </main>
</div> </div>
<!-- module bundle; ?v=<stamp> busts stale intermediate builds --> <!-- module bundle; ?v=<stamp> busts stale intermediate builds -->
<script type="module" src="/static/main.js?v=7"></script> <script type="module" src="/static/main.js?v=8"></script>
</body> </body>
</html> </html>
} }
+1 -1
View File
@@ -264,7 +264,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></main></div><!-- module bundle; ?v=<stamp> busts stale intermediate builds --><script type=\"module\" src=\"/static/main.js?v=7\"></script></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></main></div><!-- module bundle; ?v=<stamp> busts stale intermediate builds --><script type=\"module\" src=\"/static/main.js?v=8\"></script></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -262,7 +262,7 @@ templ Header(user User, currentPath string) {
</header> </header>
<!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate <!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate
builds across app restarts (browser caches are keyed by URL) --> builds across app restarts (browser caches are keyed by URL) -->
<script type="module" src="/static/main.js?v=7"></script> <script type="module" src="/static/main.js?v=8"></script>
</div> </div>
} }
+1 -1
View File
@@ -725,7 +725,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <input type=\"text\" id=\"header-search\" name=\"q\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\" x-ref=\"input\" role=\"combobox\" :aria-expanded=\"open ? 'true' : 'false'\" aria-label=\"Search your library\" @input=\"onInput()\" @focus=\"onFocus()\" @keydown.arrow-down.prevent=\"moveSelection(1)\" @keydown.arrow-up.prevent=\"moveSelection(-1)\" @keydown.enter.prevent=\"openSelected()\" @keydown.escape=\"close()\" hx-get=\"/api/media-items/search?layout=dropdown\" hx-trigger=\"search delay:300ms\" hx-target=\"#search-results\" hx-swap=\"innerHTML\"><div id=\"search-results\" x-show=\"open\" x-cloak class=\"absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"></div></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=7\"></script></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <input type=\"text\" id=\"header-search\" name=\"q\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\" x-ref=\"input\" role=\"combobox\" :aria-expanded=\"open ? 'true' : 'false'\" aria-label=\"Search your library\" @input=\"onInput()\" @focus=\"onFocus()\" @keydown.arrow-down.prevent=\"moveSelection(1)\" @keydown.arrow-up.prevent=\"moveSelection(-1)\" @keydown.enter.prevent=\"openSelected()\" @keydown.escape=\"close()\" hx-get=\"/api/media-items/search?layout=dropdown\" hx-trigger=\"search delay:300ms\" hx-target=\"#search-results\" hx-swap=\"innerHTML\"><div id=\"search-results\" x-show=\"open\" x-cloak class=\"absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"></div></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=8\"></script></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }