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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+587
-532
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user