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
+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); }