feat(web): real channel filter — multi-select of the user's channels
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:
@@ -21,6 +21,9 @@ import (
|
||||
// fake without a database.
|
||||
type Store interface {
|
||||
ListVideos(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error)
|
||||
// DistinctChannels lists the user's source channels — the options for the
|
||||
// feed's channel multi-select filter.
|
||||
DistinctChannels(ctx context.Context, userID string) ([]string, error)
|
||||
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
||||
GetVideoRow(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
||||
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
|
||||
@@ -196,7 +199,7 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
q := r.URL.Query()
|
||||
f := Filter{
|
||||
Channel: q.Get("channel"),
|
||||
Channels: nonEmptyStrings(q["channel"]),
|
||||
From: q.Get("from"),
|
||||
To: q.Get("to"),
|
||||
OnlySummarized: q.Get("summarized") == "1",
|
||||
@@ -211,6 +214,13 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
rows := f.apply(allRows)
|
||||
buckets := bucketRows(rows, a.recencyCutoff())
|
||||
|
||||
// Channel options for the multi-select filter (the user's source channels).
|
||||
channels, err := a.Store.DistinctChannels(r.Context(), userID)
|
||||
if err != nil {
|
||||
a.serverError(w, r, "distinct channels", err)
|
||||
return
|
||||
}
|
||||
|
||||
// hasConnected drives both the paste box (shown to ANY connected user, #2) and
|
||||
// the empty-state copy (a fresh account with a connection but no discovery pass
|
||||
// yet reads "connected, summaries land gradually" rather than "nothing here").
|
||||
@@ -227,7 +237,7 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, r, summaryList(buckets, hasConnected))
|
||||
return
|
||||
}
|
||||
a.render(w, r, ListPage(buckets, f, stats, takeFlash(w, r), hasConnected))
|
||||
a.render(w, r, ListPage(buckets, f, stats, takeFlash(w, r), hasConnected, channels))
|
||||
}
|
||||
|
||||
// handleDetail renders one summary in full (highlights, takeaways, action group).
|
||||
|
||||
Reference in New Issue
Block a user