fix(collections): the order select re-orders the PAGE — visible order = play order

The detail header's listening-order select persisted fine and the
reader chain honored it, but the page's card list never applied
chain_order (it kept the composed query's title order) — so flipping
the select changed nothing visible, and Play as Sequence launches from
the FIRST CARD ON THE PAGE, making the played start (and the perceived
order) disagree with the curated setting (2026-11-01 user report on
the smart podcast page).

- Smart episode collections: the page's match list re-orders through
  OrderEpisodeMedia (the same OrderEpisodes comparator the sequence
  producer uses — §10 R2's single source of order truth) before card
  building. Books-scope matches keep title order.
- Static episode playlists: the sequence now honors the curated order
  too — native keeps the manual position order (SQL), reverse flips
  it, oldest/newest re-key on publication date with feed position as
  tiebreak. Previously statics ignored chain_order entirely while the
  select rendered on their pages.

Pins: native page order (feed positions) and the reverse flip
re-ordering the SSR cards, asserted in the curated-order subtest
alongside the existing reader-chain pins. Podcast/playlist/audiobook/
RLS/dashboard/contract slices green.
This commit is contained in:
John O'Keefe committed 2026-11-01 00:49:04 -04:00
1 parent 178b1f30c5
commit 81e54f89b2
4 files changed
+77

No files matched your search

+24
View File
@@ -14,6 +14,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
@@ -1021,6 +1022,11 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) {
assert.Contains(t, h, "TreeColl New")
assert.Contains(t, h, "TreeColl New Two")
assert.NotContains(t, h, "TreeColl Old", "published 30d ago fails within_days")
// The list IS the play order: native feed order puts New
// (position 1) before New Two (position 5). Quote-bounded so
// "TreeColl New" never matches inside "TreeColl New Two".
assert.Less(t, strings.Index(h, `"TreeColl New"`), strings.Index(h, `"TreeColl New Two"`),
"native list order = feed positions")
})
t.Run("chain_follows_the_tree", func(t *testing.T) {
@@ -1073,6 +1079,24 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) {
assert.Contains(t, string(body), `"chain_order":"reverse"`,
"view_settings merge keeps chain_order across whole-map writes")
// THE PAGE follows the flip (2026-11-01 user report: the select
// changed nothing on the page): reverse puts feed position 5
// (New Two) BEFORE position 1 (New) on the rendered cards —
// the visible order is what the sequence will play, and Play
// launches from the first card.
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+collID, nil)
req.Header.Set("Authorization", "Bearer "+regToken)
req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"})
page, perr := http.DefaultClient.Do(req)
require.NoError(t, perr)
pageBody, _ := io.ReadAll(page.Body)
_ = page.Body.Close()
require.Equal(t, http.StatusOK, page.StatusCode)
ph := string(pageBody)
require.Contains(t, ph, "TreeColl New", "the page renders the smart cards at all")
assert.Less(t, strings.Index(ph, "TreeColl New Two"), strings.Index(ph, `"TreeColl New"`),
"reverse re-orders the page cards — the visible order is the play order")
// aNew is feed position 1: FIRST under native, LAST under reverse.
code, body = podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(aNew.ID.Bytes).String()+"?queue=collection:"+collID, "")
+7
View File
@@ -316,6 +316,13 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID,
if evalErr != nil {
return nil, isSmart, "books_load_error"
}
// The visible order IS the play order: the curated listening
// order (view_settings.chain_order, §10 R2) re-orders the
// episode cards exactly as the sequence producer will play
// them, so the select's effect is visible and Play launches
// from the card the user sees first (2026-11-01 user report:
// the select changed nothing on the page).
services.OrderEpisodeMedia(matches, handlers.CollectionChainOrder(collection.ViewSettings))
books = handlers.BookCardsFromMediaItems(matches, libTypes)
// Episode-awareness for smart matches (item 2.5): episode cards
// carry the play affordance (chain via ?queue=collection:).
+19
View File
@@ -295,6 +295,25 @@ func buildCollectionSequenceContext(c *echo.Context, cfg *Config, item *database
if err != nil || len(eps) == 0 {
return nil
}
// The curated listening order applies to static episode playlists
// too (§10 R2): native/"" keeps the manual position order (the SQL
// order above); reverse flips it; oldest/newest re-key on
// publication date with feed position as the tiebreak.
switch order := handlers.CollectionChainOrder(coll.ViewSettings); order {
case "reverse":
for i, j := 0, len(eps)-1; i < j; i, j = i+1, j-1 {
eps[i], eps[j] = eps[j], eps[i]
}
case "oldest", "newest":
services.OrderEpisodes(eps, order,
func(e database.GetCollectionEpisodesOrderedRow) int32 { return e.FeedPosition.Int32 },
func(e database.GetCollectionEpisodesOrderedRow) string {
if e.DatePublished.Valid {
return e.DatePublished.Time.Format("2006-01-02")
}
return ""
})
}
currentID := uuid.UUID(item.ID.Bytes).String()
idx := -1
+27
View File
@@ -1,6 +1,7 @@
package services
import (
"bookhoard/internal/database"
"fmt"
"sort"
)
@@ -53,6 +54,32 @@ func OrderEpisodes[T any](eps []T, order string, pos func(T) int32, date func(T)
}
}
// OrderEpisodeMedia applies the curated listening order to a media
// match list — the SMART collection page renders exactly what the
// sequence will play (2026-11-01 user report: flipping the order select
// changed nothing on the page, and Play launches from the first card).
// Non-episode match lists are left untouched (title order stays).
func OrderEpisodeMedia(items []database.MediaItems, order string) {
anyEpisode := false
for _, m := range items {
if m.Source == "podcast_episode" {
anyEpisode = true
break
}
}
if !anyEpisode {
return
}
OrderEpisodes(items, order,
func(m database.MediaItems) int32 { return m.FeedPosition.Int32 },
func(m database.MediaItems) string {
if m.DatePublished.Valid {
return m.DatePublished.Time.Format("2006-01-02")
}
return ""
})
}
// episodePosKey: zero-padded position so lexical date keys tiebreak
// numerically.
func episodePosKey(pos int32) string {