feat(web): real channel filter — multi-select of the user's channels
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 10s

The free-text 'channel' filter was dead: it exact-matched SummaryRow.Channel,
which is just the provider ('youtube'), because videos never stored their source
channel. Now they do.

- migration 014: videos.channel_title (nullable; existing rows backfill on the
  next discovery pass, pasted videos immediately).
- discovery (NewVideos) + paste (VideoByID) populate channel_title; UpsertVideo
  persists it, preserving an existing title when an update arrives empty.
- store.DistinctChannels lists a user's channels (RLS-scoped); SummaryRow carries
  ChannelTitle via the shared projection.
- Filter: single Channel -> Channels []string, matching on ChannelTitle; the feed
  renders a multi-select of DistinctChannels (hidden until channels exist).
- migrate tests: 014 reversibility + fixed the relative-step counts in the 010/011
  up/down tests (014 shifted the topology).

TDD throughout: channel persist + distinct, adapter channel wiring, multi-channel
filter match, handler channel filter, migration up/down.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:02:54 +02:00
co-authored by Claude Opus 4.8
parent 1e65c3b413
commit f66c1bcdcc
16 changed files with 789 additions and 581 deletions
+13 -4
View File
@@ -104,14 +104,14 @@ templ flashBanner(code string) {
// #summary-list region; a non-HTMX request renders the whole page. flash carries
// a one-shot notification (e.g. "connected", "registered") surfaced on arrival
// after a POST→redirect.
templ ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool) {
templ ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool, channels []string) {
@Layout("Tapir — Summaries") {
@flashBanner(flash)
if hasConnected {
@pasteForm()
}
if !b.empty() || f.active() {
@filterForm(f)
@filterForm(f, channels)
}
if stats.RateLimited > 0 || stats.Pending > 0 || stats.NoText > 0 {
@pipelineBar(stats)
@@ -168,7 +168,7 @@ templ pasteForm() {
<div id="paste-result"></div>
}
templ filterForm(f Filter) {
templ filterForm(f Filter, channels []string) {
<form
class="filters"
method="get"
@@ -178,7 +178,16 @@ templ filterForm(f Filter) {
hx-swap="innerHTML"
hx-indicator="#filter-indicator"
>
<label>Channel <input type="text" name="channel" value={ f.Channel } placeholder="any"/></label>
if len(channels) > 0 {
<label>
Channels
<select name="channel" multiple size="4">
for _, c := range channels {
<option value={ c } selected?={ f.HasChannel(c) }>{ c }</option>
}
</select>
</label>
}
<label class="filter-check">
<input type="checkbox" name="summarized" value="1" if f.OnlySummarized { checked }/>
Summarized only