feat(web): collapse older + caption-less videos in the list

Stop the un-summarized back-catalogue from burying the readable summaries
(UX review B3/B4). One feed, with a noise-collapse — not sections:

- Summarized + recent un-summarized videos lead inline as cards.
- Un-summarized videos older than the recency window collapse into a single
  "Show N older videos — summarize on demand" disclosure (they will not
  auto-fill; they are manual-only). Window comes from App.RecencyWindow
  (= cfg.AutoSummarizeWindow); 0 disables the collapse (all inline).
- Caption-less videos collapse into one honest line ("N videos have no
  captions and can't be summarized") instead of N dead terminal cards.

bucketRows is a pure classifier (cutoff-driven; undated rows never age out);
App gains RecencyWindow + an injectable clock for the cutoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:55:07 +02:00
co-authored by Claude Opus 4.8
parent 3df0459fed
commit 40b703e02a
6 changed files with 748 additions and 546 deletions
+34 -5
View File
@@ -5,6 +5,7 @@ import (
"errors"
"log/slog"
"net/http"
"time"
"github.com/a-h/templ"
@@ -80,6 +81,32 @@ type App struct {
// Processing tracks in-flight immediate summarizations so the status endpoint
// shows the animation until the summary lands. The zero value is ready to use.
Processing ProcessingSet
// RecencyWindow mirrors the auto-summarize recency bound: un-summarized videos
// published before now-RecencyWindow collapse into the "older videos"
// disclosure on the list, so the readable summaries are not buried (B3). Zero
// disables the collapse (everything stays inline).
RecencyWindow time.Duration
// Now is an injectable clock for the recency cutoff (tests fix it). Nil =
// time.Now.
Now func() time.Time
}
// now returns the App's clock (time.Now unless overridden for tests).
func (a *App) now() time.Time {
if a.Now != nil {
return a.Now()
}
return time.Now()
}
// recencyCutoff is the timestamp before which an un-summarized video counts as
// "older" and collapses into the disclosure. A zero RecencyWindow yields the zero
// time, which bucketRows treats as "collapse disabled".
func (a *App) recencyCutoff() time.Time {
if a.RecencyWindow <= 0 {
return time.Time{}
}
return a.now().Add(-a.RecencyWindow)
}
func (a *App) logger() *slog.Logger {
@@ -169,12 +196,14 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
}
stats := pipelineStats(allRows)
rows := f.apply(allRows)
buckets := bucketRows(rows, a.recencyCutoff())
// hasConnected drives the empty state: a fresh account with a connection but
// no `tapir run` yet has zero rows, and we want it to read "connected, run
// tapir" rather than "nothing here". Only needed when the list is empty.
// no discovery pass yet has zero rows, and we want it to read "connected,
// summaries land gradually" rather than "nothing here". Only needed when the
// list is empty.
hasConnected := false
if len(rows) == 0 {
if buckets.empty() {
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
if err != nil {
a.serverError(w, r, "connections for user", err)
@@ -184,10 +213,10 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
}
if isHTMX(r) {
a.render(w, r, summaryList(rows, hasConnected))
a.render(w, r, summaryList(buckets, hasConnected))
return
}
a.render(w, r, ListPage(rows, f, stats, takeFlash(w, r), hasConnected))
a.render(w, r, ListPage(buckets, f, stats, takeFlash(w, r), hasConnected))
}
// handleDetail renders one summary in full (highlights, takeaways, action group).