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