feat(podcasts): reverse order — position DESC, date-agnostic; single order helper

The 'Oldest first' order sorts by PUB DATE, which breaks on Scribl's
migrated podiobooks feeds (messy/all-equal dates). New order value
'reverse' = feed position DESC with a date-DESC tiebreak — dates are
not involved, so nothing can break.

- services.OrderEpisodes: THE single order source of truth (§0 items
  3+5a). The four surfaces (sequence JSON, reader chain context, SSR
  page list) previously carried three near-identical handwritten
  comparators — all now route through one generic helper. The SSR page
  list gains a SortDate key (lexical 2006-01-02) so the pub-date
  tiebreak is actually chronological; the old comparator compared
  'Jan 2, 2006' display strings alphabetically (latent drift bug).
- Dropdown becomes four options: Feed order / Feed order (reversed) /
  Oldest first (pub date) / Newest first (pub date).
- Serial suggestion button renamed 'Reverse feed order' (sets reverse,
  not oldest) and its response out-of-band-swaps the order select
  (PodcastOrderSelectOOB — same mechanism as Download All's policy
  select), so the dropdown mirrors the server state immediately.
- Validation: oneof=native reverse oldest newest everywhere.
- Tests: reverse binds via the form wire; messy-dates sequence
  returns 3,2,1; existing reorder-safety stays green.
This commit is contained in:
John O'Keefe committed 2026-10-30 09:15:03 -04:00
1 parent 7c7c08748e
commit e3675536aa
9 files changed
+708 -463

No files matched your search

+30 -6
View File
@@ -116,21 +116,24 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
hx-swap="outerHTML"
>
<option value="native" selected={ data.Order == "native" }>Feed order</option>
<option value="oldest" selected={ data.Order == "oldest" }>Oldest first</option>
<option value="newest" selected={ data.Order == "newest" }>Newest first</option>
<option value="reverse" selected={ data.Order == "reverse" }>Feed order (reversed)</option>
<option value="oldest" selected={ data.Order == "oldest" }>Oldest first (pub date)</option>
<option value="newest" selected={ data.Order == "newest" }>Newest first (pub date)</option>
</select>
<!-- Serial suggestion (D5): one-click, never silent -->
<!-- Serial suggestion (D5, §0 item 3 rename): one-click,
never silent. "reverse" sorts by FEED POSITION DESC —
no pub dates involved, so messy dates can't break it. -->
if data.IsSerial && data.Order == "native" {
<button
class="btn btn-secondary text-sm"
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
hx-vals='{"order": "oldest"}'
hx-vals='{"order": "reverse"}'
hx-target="#episode-list"
hx-swap="outerHTML"
title="Serial feed: publishers list newest first — listen oldest-first"
title="Serial feed: publishers list newest first — reverse the feed order to start at chapter 1"
>
<span>Serial? Listen oldest-first</span>
<span>Reverse feed order</span>
</button>
}
@@ -382,6 +385,27 @@ templ PodcastAutoDownloadSelect(feed PodcastFeedCard) {
</select>
}
// PodcastOrderSelectOOB is the out-of-band variant of the order
// dropdown: same element id + hx-swap-oob so htmx replaces the
// action-row select in place (§0 item 3 — the "Reverse feed order"
// button flips the order server-side; the dropdown must show it).
templ PodcastOrderSelectOOB(feedID string, order string) {
<select
id="podcast-order-select"
name="order"
hx-swap-oob="true"
class="input w-auto py-1.5 cursor-pointer"
hx-put={ "/api/podcasts/feeds/" + feedID + "/order" }
hx-target="#episode-list"
hx-swap="outerHTML"
>
<option value="native" selected={ order == "native" }>Feed order</option>
<option value="reverse" selected={ order == "reverse" }>Feed order (reversed)</option>
<option value="oldest" selected={ order == "oldest" }>Oldest first (pub date)</option>
<option value="newest" selected={ order == "newest" }>Newest first (pub date)</option>
</select>
}
// PodcastAutoDownloadSelectOOB is the out-of-band variant: same element
// id + hx-swap-oob so htmx replaces the action-row select in place.
templ PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) {