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
+1 -1
View File
@@ -193,7 +193,7 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
// The file-backed SecretStore is shared by the connect flow (writes tokens)
// and account management (deletes them on disconnect / delete-account).
secretStore := secrets.NewFileStore(cfg.SecretsFile)
app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log}
app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log, RecencyWindow: cfg.AutoSummarizeWindow}
// User onboarding is handled by the IdP (Authentik invite flow), not Tapir —
// the Dex local-password provisioning path was removed (ADR-019). An
+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).
+42
View File
@@ -318,6 +318,48 @@ func TestListShowsSummarizeButtonForUnsummarized(t *testing.T) {
require.NotContains(t, html, "Queued", "not queued yet")
}
// TestListCollapsesOlderAndNoCaption verifies the feed IA (UX review B3/B4):
// summarized + recent un-summarized cards lead inline; older un-summarized
// videos collapse into a single disclosure; caption-less videos collapse into a
// one-line count instead of dead cards.
func TestListCollapsesOlderAndNoCaption(t *testing.T) {
ctx := context.Background()
app := newApp(t)
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
app.RecencyWindow = 7 * 24 * time.Hour
app.Now = func() time.Time { return now }
p := rawPool(t)
resetDB(t, p)
const (
vRecent = "cccccccc-cccc-cccc-cccc-cccccccccccc" // 2d old, unsummarized → inline
vOld = "dddddddd-dddd-dddd-dddd-dddddddddddd" // 30d old, unsummarized → disclosure
vNoCap = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" // caption-less → collapsed line
)
require.NoError(t, deliver(ctx, app, videoX, "body x")) // summarized, recent
seedVideo(t, p, videoX, "Summarized X", "https://x", now.Add(-24*time.Hour))
seedVideo(t, p, vRecent, "Recent Pending", "https://r", now.Add(-2*24*time.Hour))
seedVideo(t, p, vOld, "Old Pending", "https://o", now.Add(-30*24*time.Hour))
seedVideo(t, p, vNoCap, "No Caption Vid", "https://n", now.Add(-40*24*time.Hour))
_, err := p.Exec(ctx, `UPDATE videos SET transcript_status = 'none' WHERE id = $1`, vNoCap)
require.NoError(t, err)
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
disclosure := strings.Index(html, "Show 1 older videos")
require.GreaterOrEqual(t, disclosure, 0, "older-videos disclosure present")
// Summarized + recent un-summarized lead inline, above the disclosure.
require.Less(t, strings.Index(html, "Summarized X"), disclosure, "summarized card is inline")
require.Less(t, strings.Index(html, "Recent Pending"), disclosure, "recent pending is inline")
// The older video is hidden inside the disclosure, after its summary.
require.Greater(t, strings.Index(html, "Old Pending"), disclosure, "older video lives in the disclosure")
// Caption-less video is a one-line count, never a card.
require.Contains(t, html, "have no captions")
require.NotContains(t, html, "No Caption Vid", "caption-less video is collapsed, not a card")
}
func TestRequestSummarizeQueuesAndRendersCard(t *testing.T) {
ctx := context.Background()
app := newApp(t)
+60
View File
@@ -414,6 +414,57 @@ func retryNowURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/retry-now")
}
// listBuckets splits the (already filtered) video list into what the list view
// shows where, so the readable summaries are not buried under the un-summarized
// back-catalogue (UX review B3/B4). It is one feed with a noise-collapse, not
// separate sections:
// - Main: summarized videos + recent un-summarized ones — shown inline as cards.
// - Older: un-summarized videos published before the recency cutoff — collapsed
// behind a single "Show N older videos" disclosure (they will not auto-fill;
// they are summarize-on-demand).
// - NoCaption: count of un-summarized videos with no caption track — collapsed
// to one honest line instead of N dead terminal cards.
type listBuckets struct {
Main []store.SummaryRow
Older []store.SummaryRow
NoCaption int
}
// bucketRows classifies rows into the list buckets given a recency cutoff. A zero
// cutoff (recency collapse disabled) leaves Older empty — every un-summarized,
// captioned video stays inline. Order within each bucket is preserved.
func bucketRows(rows []store.SummaryRow, cutoff time.Time) listBuckets {
var b listBuckets
for _, r := range rows {
switch {
case r.Summarized:
b.Main = append(b.Main, r)
case r.TranscriptStatus == "none":
b.NoCaption++
case isOlder(r, cutoff):
b.Older = append(b.Older, r)
default:
b.Main = append(b.Main, r)
}
}
return b
}
// isOlder reports whether an un-summarized row falls before the recency cutoff.
// A zero cutoff (window disabled) or an undated row is never "older" — it cannot
// be aged out, so it stays inline rather than being hidden in the disclosure.
func isOlder(r store.SummaryRow, cutoff time.Time) bool {
if cutoff.IsZero() || r.PublishedAt.IsZero() {
return false
}
return r.PublishedAt.Before(cutoff)
}
// empty reports whether there is nothing to show at all (drives the empty state).
func (b listBuckets) empty() bool {
return len(b.Main) == 0 && len(b.Older) == 0 && b.NoCaption == 0
}
// Filter holds the list-view query parameters. Empty fields mean "no constraint".
// Dates are kept as the raw YYYY-MM-DD strings so the form re-renders the user's
// input verbatim; parsing happens in matchFilter.
@@ -539,6 +590,15 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
.pipeline-bar span { display: flex; align-items: center; gap: var(--s1); }
.pipeline-bar span + span::before { content: "·"; margin-right: var(--s1); }
.pipeline-note { margin: calc(-1 * var(--s2)) 0 var(--s3); font-size: .8rem; line-height: 1.5; max-width: 40rem; }
/* one-line count of caption-less videos (collapsed instead of N dead cards) */
.list-note { margin: var(--s3) 0 0; font-size: .85rem; }
/* older un-summarized back-catalogue, collapsed behind a disclosure so it does
not bury the readable summaries above it */
.older-videos { margin-top: var(--s4); }
.older-videos > summary { cursor: pointer; font-size: .85rem; font-weight: 600; color: var(--accent); padding: var(--s2) 0; list-style: revert; }
.older-videos > summary:hover { text-decoration: underline; }
.older-videos[open] > summary { margin-bottom: var(--s3); }
.older-videos .cards { margin-top: 0; }
.card-nudge-form { display: inline; }
.btn-quiet { font: inherit; font-size: .72rem; font-weight: 600; padding: .15rem .55rem; border-radius: 999px; border: 1px solid var(--accent); background: transparent; color: var(--accent); cursor: pointer; }
.btn-quiet:hover { background: var(--accent-weak); }
+24 -8
View File
@@ -104,7 +104,7 @@ 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(rows []store.SummaryRow, f Filter, stats PipelineStats, flash string, hasConnected bool) {
templ ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool) {
@Layout("Tapir — Summaries") {
@flashBanner(flash)
@filterForm(f)
@@ -118,7 +118,7 @@ templ ListPage(rows []store.SummaryRow, f Filter, stats PipelineStats, flash str
</p>
}
<div id="summary-list">
@summaryList(rows, hasConnected)
@summaryList(b, hasConnected)
</div>
}
}
@@ -163,11 +163,14 @@ templ filterForm(f Filter) {
</form>
}
// summaryList is the swappable list fragment: one card per video (summarized or
// not). Cards reflow to a single column on mobile; an empty list shows a friendly
// first-run state instead of a blank table.
templ summaryList(rows []store.SummaryRow, hasConnected bool) {
if len(rows) == 0 {
// summaryList is the swappable list fragment. It leads with readable summaries +
// recent un-summarized cards (b.Main), then collapses the noise so it does not
// bury the payload (UX review B3/B4): a one-line count of caption-less videos,
// and a single disclosure holding the older un-summarized back-catalogue. Cards
// reflow to a single column on mobile; an empty list shows a friendly first-run
// state instead of a blank table.
templ summaryList(b listBuckets, hasConnected bool) {
if b.empty() {
if hasConnected {
<div class="empty empty-connected">
<strong>Your account is connected</strong>
@@ -182,10 +185,23 @@ templ summaryList(rows []store.SummaryRow, hasConnected bool) {
}
} else {
<ul class="cards">
for _, r := range rows {
for _, r := range b.Main {
@VideoCard(r)
}
</ul>
if b.NoCaption > 0 {
<p class="list-note muted">{ fmt.Sprintf("%d video(s) have no captions and can't be summarized.", b.NoCaption) }</p>
}
if len(b.Older) > 0 {
<details class="older-videos">
<summary>{ fmt.Sprintf("Show %d older videos — summarize on demand", len(b.Older)) }</summary>
<ul class="cards">
for _, r := range b.Older {
@VideoCard(r)
}
</ul>
</details>
}
}
}
File diff suppressed because it is too large Load Diff