feat(web): pipeline stats bar, summarized-first sort, Try now button for rate-limited videos
Three UX improvements for the pending-transcript state:
1. Summarized videos sort to top (ORDER BY (s.id IS NOT NULL) DESC, seen_at DESC)
so completed summaries are always immediately visible without filtering.
ListVideos default limit raised from 50 to 500 to show the full backlog.
2. Pipeline stats bar above the video list: '2 summarized · 256 fetching soon · 12
no captions' — computed from the unfiltered row set, hidden when everything is
summarized.
3. 'Try now' button on rate-limited cards replaces the passive 'Retrying later'
chip. POST /v/{id}/retry-now clears rate_limited_at then calls ProcessVideo
through the shared globalFetchGate — same rate limiting as the scheduler, safe
under concurrent use.
This commit is contained in:
@@ -141,20 +141,20 @@ const selectVideo = `
|
|||||||
FROM videos v
|
FROM videos v
|
||||||
LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id`
|
LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id`
|
||||||
|
|
||||||
// ListVideos returns ALL of the user's videos — summarized and not — most recent
|
// ListVideos returns ALL of the user's videos — summarized first then most recent
|
||||||
// first by seen_at, capped at limit (non-positive defaults to 50). Unsummarized
|
// by seen_at — capped at limit (non-positive defaults to 500). Unsummarized
|
||||||
// videos come back with Summarized=false and empty summary fields, so the list
|
// videos come back with Summarized=false and empty summary fields, so the list
|
||||||
// view can render them with a "Summarize" affordance. Scoped by user_id.
|
// view can render them with a "Summarize" affordance. Scoped by user_id.
|
||||||
func (s *Store) ListVideos(ctx context.Context, userID string, limit int) ([]SummaryRow, error) {
|
func (s *Store) ListVideos(ctx context.Context, userID string, limit int) ([]SummaryRow, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 500
|
||||||
}
|
}
|
||||||
var out []SummaryRow
|
var out []SummaryRow
|
||||||
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
rows, err := tx.Query(ctx,
|
rows, err := tx.Query(ctx,
|
||||||
selectVideo+`
|
selectVideo+`
|
||||||
WHERE v.user_id = $1
|
WHERE v.user_id = $1
|
||||||
ORDER BY v.seen_at DESC
|
ORDER BY (s.id IS NOT NULL) DESC, v.seen_at DESC
|
||||||
LIMIT $2`,
|
LIMIT $2`,
|
||||||
userID, limit)
|
userID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ type Store interface {
|
|||||||
// the most recent discovery pass, shown on the account page as a warning.
|
// the most recent discovery pass, shown on the account page as a warning.
|
||||||
ListChannelErrors(ctx context.Context, userID string) ([]store.ChannelError, error)
|
ListChannelErrors(ctx context.Context, userID string) ([]store.ChannelError, error)
|
||||||
|
|
||||||
|
// SetTranscriptStatus clears or updates a video's transcript backoff state.
|
||||||
|
// Used by handleRetryNow to clear rate_limited_at before immediate processing.
|
||||||
|
SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error
|
||||||
|
|
||||||
// StampLogin records (throttled, one row per user per day) that the resolved
|
// StampLogin records (throttled, one row per user per day) that the resolved
|
||||||
// user was active on this request — the read-side Stage-0 usage signal the
|
// user was active on this request — the read-side Stage-0 usage signal the
|
||||||
// registration gate stamps for every authenticated request.
|
// registration gate stamps for every authenticated request.
|
||||||
@@ -113,6 +117,7 @@ func (a *App) Router() http.Handler {
|
|||||||
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
||||||
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
||||||
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
||||||
|
app.HandleFunc("POST /v/{videoId}/retry-now", a.handleRetryNow)
|
||||||
app.HandleFunc("GET /v/{videoId}/status", a.handleStatus)
|
app.HandleFunc("GET /v/{videoId}/status", a.handleStatus)
|
||||||
app.HandleFunc("GET /register", a.handleRegisterForm)
|
app.HandleFunc("GET /register", a.handleRegisterForm)
|
||||||
app.HandleFunc("POST /register", a.handleRegister)
|
app.HandleFunc("POST /register", a.handleRegister)
|
||||||
@@ -170,12 +175,13 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
|||||||
OnlySummarized: q.Get("summarized") == "1",
|
OnlySummarized: q.Get("summarized") == "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := a.Store.ListVideos(r.Context(), userID, 0)
|
allRows, err := a.Store.ListVideos(r.Context(), userID, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.serverError(w, r, "list videos", err)
|
a.serverError(w, r, "list videos", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows = f.apply(rows)
|
stats := pipelineStats(allRows)
|
||||||
|
rows := f.apply(allRows)
|
||||||
|
|
||||||
// hasConnected drives the empty state: a fresh account with a connection but
|
// 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
|
// no `tapir run` yet has zero rows, and we want it to read "connected, run
|
||||||
@@ -194,7 +200,7 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.render(w, r, summaryList(rows, hasConnected))
|
a.render(w, r, summaryList(rows, hasConnected))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.render(w, r, ListPage(rows, f, takeFlash(w, r), hasConnected))
|
a.render(w, r, ListPage(rows, f, stats, takeFlash(w, r), hasConnected))
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDetail renders one summary in full (highlights, takeaways, action group).
|
// handleDetail renders one summary in full (highlights, takeaways, action group).
|
||||||
@@ -302,6 +308,40 @@ func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.render(w, r, VideoCard(*row))
|
a.render(w, r, VideoCard(*row))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRetryNow handles the "Try now" button on rate-limited video cards. It
|
||||||
|
// clears the rate_limited_at backoff so the scheduler won't skip the video, then
|
||||||
|
// triggers an immediate ProcessVideo — same background path as handleRequestSummarize.
|
||||||
|
// The rate gate (globalFetchGate) still applies, so this is safe under concurrent use.
|
||||||
|
func (a *App) handleRetryNow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
videoID := r.PathValue("videoId")
|
||||||
|
|
||||||
|
// Clear the backoff so the scheduler won't skip this video on the next pass.
|
||||||
|
if err := a.Store.SetTranscriptStatus(r.Context(), userID, videoID, "none"); err != nil {
|
||||||
|
a.serverError(w, r, "clear rate limit", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isHTMX(r) {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "get video", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.Processor != nil {
|
||||||
|
a.startProcessing(userID, videoID)
|
||||||
|
a.render(w, r, processingCard(*row))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, VideoCard(*row))
|
||||||
|
}
|
||||||
|
|
||||||
// startProcessing marks a video in-flight and summarizes it in the background.
|
// startProcessing marks a video in-flight and summarizes it in the background.
|
||||||
// The goroutine uses a detached context — not the request's, which is cancelled
|
// The goroutine uses a detached context — not the request's, which is cancelled
|
||||||
// when the handler returns — and clears the in-flight mark on completion. On
|
// when the handler returns — and clears the in-flight mark on completion. On
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ func renderVideoCard(t *testing.T, r store.SummaryRow) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// A rate-limited, unsummarized video shows the passive "Retrying later" badge and
|
// A rate-limited, unsummarized video shows an active "Try now" button so the user
|
||||||
// hides the Summarize button — the user can't fix it, retry is automatic.
|
// can manually trigger an immediate fetch through the shared rate gate.
|
||||||
func TestVideoCard_RateLimitedShowsRetryingBadge(t *testing.T) {
|
func TestVideoCard_RateLimitedShowsRetryingBadge(t *testing.T) {
|
||||||
html := renderVideoCard(t, store.SummaryRow{
|
html := renderVideoCard(t, store.SummaryRow{
|
||||||
VideoID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
VideoID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||||
@@ -27,11 +27,11 @@ func TestVideoCard_RateLimitedShowsRetryingBadge(t *testing.T) {
|
|||||||
TranscriptStatus: "rate_limited",
|
TranscriptStatus: "rate_limited",
|
||||||
})
|
})
|
||||||
|
|
||||||
if !strings.Contains(html, "Retrying later") {
|
if !strings.Contains(html, "Try now") {
|
||||||
t.Errorf("expected a 'Retrying later' badge, got:\n%s", html)
|
t.Errorf("expected a 'Try now' button, got:\n%s", html)
|
||||||
}
|
}
|
||||||
if !strings.Contains(html, "chip-retry") {
|
if !strings.Contains(html, "retry-now") {
|
||||||
t.Errorf("expected the passive chip-retry styling, got:\n%s", html)
|
t.Errorf("expected the retry-now route in the form action, got:\n%s", html)
|
||||||
}
|
}
|
||||||
if strings.Contains(html, ">Summarize<") {
|
if strings.Contains(html, ">Summarize<") {
|
||||||
t.Errorf("the Summarize button must be hidden for a rate-limited video, got:\n%s", html)
|
t.Errorf("the Summarize button must be hidden for a rate-limited video, got:\n%s", html)
|
||||||
|
|||||||
@@ -388,6 +388,38 @@ func disconnectURL(provider string) templ.SafeURL {
|
|||||||
return templ.SafeURL("/account/disconnect/" + provider)
|
return templ.SafeURL("/account/disconnect/" + provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PipelineStats summarises the user's video backlog so the list page can show
|
||||||
|
// a one-line status bar ("2 summaries · 256 fetching soon · 12 no captions").
|
||||||
|
type PipelineStats struct {
|
||||||
|
Summarized int
|
||||||
|
RateLimited int // in the backoff window, will be retried
|
||||||
|
NoText int // no caption track available
|
||||||
|
Pending int // discovered but not yet attempted
|
||||||
|
}
|
||||||
|
|
||||||
|
// pipelineStats computes a PipelineStats from all (unfiltered) rows.
|
||||||
|
func pipelineStats(rows []store.SummaryRow) PipelineStats {
|
||||||
|
var s PipelineStats
|
||||||
|
for _, r := range rows {
|
||||||
|
switch {
|
||||||
|
case r.Summarized:
|
||||||
|
s.Summarized++
|
||||||
|
case r.TranscriptStatus == "rate_limited":
|
||||||
|
s.RateLimited++
|
||||||
|
case r.TranscriptStatus == "none":
|
||||||
|
s.NoText++
|
||||||
|
default:
|
||||||
|
s.Pending++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryNowURL builds the POST path for manual retry of a rate-limited video.
|
||||||
|
func retryNowURL(videoID string) templ.SafeURL {
|
||||||
|
return templ.SafeURL("/v/" + videoID + "/retry-now")
|
||||||
|
}
|
||||||
|
|
||||||
// Filter holds the list-view query parameters. Empty fields mean "no constraint".
|
// 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
|
// Dates are kept as the raw YYYY-MM-DD strings so the form re-renders the user's
|
||||||
// input verbatim; parsing happens in matchFilter.
|
// input verbatim; parsing happens in matchFilter.
|
||||||
@@ -509,6 +541,12 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
|
|||||||
/* passive "retrying later" chip: dim/grey (CharmDim), not the accent — it is a
|
/* passive "retrying later" chip: dim/grey (CharmDim), not the accent — it is a
|
||||||
status, not an action the user can take. */
|
status, not an action the user can take. */
|
||||||
.chip-retry { background: rgba(108, 108, 108, .16); color: #6c6c6c; }
|
.chip-retry { background: rgba(108, 108, 108, .16); color: #6c6c6c; }
|
||||||
|
.pipeline-bar { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; margin-bottom: var(--s3); font-size: .8rem; color: var(--muted); }
|
||||||
|
.pipeline-bar span { display: flex; align-items: center; gap: var(--s1); }
|
||||||
|
.pipeline-bar span + span::before { content: "·"; margin-right: var(--s1); }
|
||||||
|
.retry-form { display: inline; }
|
||||||
|
.btn-retry { 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-retry:hover { background: var(--accent-weak); }
|
||||||
.chip-warn { background: rgba(255, 110, 156, .15); color: #FF6E9C; }
|
.chip-warn { background: rgba(255, 110, 156, .15); color: #FF6E9C; }
|
||||||
.card-state { color: var(--muted); font-size: .8rem; }
|
.card-state { color: var(--muted); font-size: .8rem; }
|
||||||
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; }
|
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; }
|
||||||
|
|||||||
@@ -104,16 +104,36 @@ templ flashBanner(code string) {
|
|||||||
// #summary-list region; a non-HTMX request renders the whole page. flash carries
|
// #summary-list region; a non-HTMX request renders the whole page. flash carries
|
||||||
// a one-shot notification (e.g. "connected", "registered") surfaced on arrival
|
// a one-shot notification (e.g. "connected", "registered") surfaced on arrival
|
||||||
// after a POST→redirect.
|
// after a POST→redirect.
|
||||||
templ ListPage(rows []store.SummaryRow, f Filter, flash string, hasConnected bool) {
|
templ ListPage(rows []store.SummaryRow, f Filter, stats PipelineStats, flash string, hasConnected bool) {
|
||||||
@Layout("Tapir — Summaries") {
|
@Layout("Tapir — Summaries") {
|
||||||
@flashBanner(flash)
|
@flashBanner(flash)
|
||||||
@filterForm(f)
|
@filterForm(f)
|
||||||
|
if stats.RateLimited > 0 || stats.Pending > 0 || stats.NoText > 0 {
|
||||||
|
@pipelineBar(stats)
|
||||||
|
}
|
||||||
<div id="summary-list">
|
<div id="summary-list">
|
||||||
@summaryList(rows, hasConnected)
|
@summaryList(rows, hasConnected)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ pipelineBar(s PipelineStats) {
|
||||||
|
<div class="pipeline-bar">
|
||||||
|
if s.Summarized > 0 {
|
||||||
|
<span>{ fmt.Sprintf("%d summarized", s.Summarized) }</span>
|
||||||
|
}
|
||||||
|
if s.RateLimited > 0 {
|
||||||
|
<span>{ fmt.Sprintf("%d fetching soon", s.RateLimited) }</span>
|
||||||
|
}
|
||||||
|
if s.Pending > 0 {
|
||||||
|
<span>{ fmt.Sprintf("%d pending", s.Pending) }</span>
|
||||||
|
}
|
||||||
|
if s.NoText > 0 {
|
||||||
|
<span class="muted">{ fmt.Sprintf("%d no captions", s.NoText) }</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
templ filterForm(f Filter) {
|
templ filterForm(f Filter) {
|
||||||
<form
|
<form
|
||||||
class="filters"
|
class="filters"
|
||||||
@@ -194,7 +214,16 @@ templ VideoCard(r store.SummaryRow) {
|
|||||||
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
|
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
|
||||||
}
|
}
|
||||||
} else if r.TranscriptStatus == "rate_limited" {
|
} else if r.TranscriptStatus == "rate_limited" {
|
||||||
<span class="chip chip-retry" title="Caption fetch was rate-limited; tapir will retry automatically.">⏳ Retrying later</span>
|
<form
|
||||||
|
method="post"
|
||||||
|
action={ retryNowURL(r.VideoID) }
|
||||||
|
hx-post={ string(retryNowURL(r.VideoID)) }
|
||||||
|
hx-target={ "#video-" + r.VideoID }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="retry-form"
|
||||||
|
>
|
||||||
|
<button type="submit" class="btn-retry" title="Fetch transcript now through the shared rate gate">Try now</button>
|
||||||
|
</form>
|
||||||
} else if r.SummarizeRequested {
|
} else if r.SummarizeRequested {
|
||||||
<span class="chip">Queued</span>
|
<span class="chip">Queued</span>
|
||||||
<span class="card-state muted">waiting for the next run</span>
|
<span class="card-state muted">waiting for the next run</span>
|
||||||
|
|||||||
+691
-533
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user