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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user