diff --git a/cmd/server/tests/series_sort_filter_test.go b/cmd/server/tests/series_sort_filter_test.go
index 3c385f87..859151ea 100644
--- a/cmd/server/tests/series_sort_filter_test.go
+++ b/cmd/server/tests/series_sort_filter_test.go
@@ -176,6 +176,9 @@ func TestSeriesSortAndFilter(t *testing.T) {
{"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=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 {
got := renderedNames("&" + tc.query)
diff --git a/internal/services/series_parsing_test.go b/internal/services/series_parsing_test.go
index 3b4ba009..e829f003 100644
--- a/internal/services/series_parsing_test.go
+++ b/internal/services/series_parsing_test.go
@@ -320,3 +320,40 @@ func TestSeriesNumberFloatOrderingValues(t *testing.T) {
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)
+ }
+ }
+}
diff --git a/internal/services/series_service.go b/internal/services/series_service.go
index 0181bf7a..88a4df11 100644
--- a/internal/services/series_service.go
+++ b/internal/services/series_service.go
@@ -91,6 +91,15 @@ func ResolveSeriesSort(sort, dir string) (string, string) {
if dir != "asc" && dir != "desc" {
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.
if seriesSortKeys[sort] {
return sort, seriesDirOf(sort)
@@ -101,7 +110,9 @@ func ResolveSeriesSort(sort, dir string) (string, string) {
}
defaultDir, known := seriesBaseSorts[base]
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 == "" {
dir = defaultDir
diff --git a/templates/docs.templ b/templates/docs.templ
index 089366ef..112db597 100644
--- a/templates/docs.templ
+++ b/templates/docs.templ
@@ -72,7 +72,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
-
+