feat(web): honest, state-aware summarize status with charm spinner (ADR-025)
CI / Lint / Test / Vet (push) Successful in 12s
CI / Build & Import (push) Successful in 10s

Clicking Summarize polled /status, which knew only "spinning" or "done". The web
ProcessVideo recorded an outcome only on success, so a 429'd or caption-less
click left transcript_status unset and the poll silently reverted to the
Summarize button — the rate limit was invisible and the click felt broken.

- ProcessVideo now records rate_limited / none / fetched (mirrors the runner); a
  rate-limited video keeps its requested flag so the background sweep retries it.
- /status is state-aware: summary card (done), working spinner (in-flight),
  a calm "waiting on rate limit, will retry" card that keeps polling so the
  summary lands on its own (no re-click), and a terminal "no captions" card.
- Charm status text (Claude-Code / Crush inspired): the spinner cycles playful
  tapir-themed gerunds via CSS only (no JS), decorative + an sr-only stable
  status line for a11y.

Pillar A (foreground fetch priority lane) is a separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 21:52:33 +02:00
co-authored by Claude Opus 4.8
parent 5219561a91
commit 1665a1e7c4
7 changed files with 579 additions and 223 deletions
+40
View File
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/web"
@@ -129,3 +130,42 @@ func getStatus(t *testing.T, app *web.App, videoID string) *httptest.ResponseRec
app.Router().ServeHTTP(rec, req)
return rec
}
// setTranscriptStatus stamps videos.transcript_status directly (bypassing RLS via
// the super pool) so a test can drive the status endpoint into a given state.
func setTranscriptStatus(t *testing.T, p *pgxpool.Pool, videoID, status string) {
t.Helper()
_, err := p.Exec(context.Background(),
`UPDATE videos SET transcript_status = $2 WHERE id = $1`, videoID, status)
require.NoError(t, err)
}
// TestStatusRateLimitedShowsWaitingCard: a click that hit YouTube's rate limit
// must surface the honest "waiting, will retry" card that keeps polling — not a
// silent revert to the Summarize button.
func TestStatusRateLimitedShowsWaitingCard(t *testing.T) {
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
seedVideo(t, p, videoX, "Throttled Title", "https://x", time.Time{})
setTranscriptStatus(t, p, videoX, "rate_limited")
html := body(t, getStatus(t, app, videoX))
require.Contains(t, html, "Waiting on YouTube rate limits", "honest rate-limit copy")
require.Contains(t, html, `hx-trigger="every 30s"`, "waiting card keeps polling so it self-resolves")
require.NotContains(t, html, "Summarize this video", "must not revert to the Summarize button")
}
// TestStatusNoCaptionsTerminal: a no-captions outcome is terminal — an honest
// message, no poll, no button to click back into the same dead end.
func TestStatusNoCaptionsTerminal(t *testing.T) {
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
seedVideo(t, p, videoX, "Silent Title", "https://x", time.Time{})
setTranscriptStatus(t, p, videoX, "none")
html := body(t, getStatus(t, app, videoX))
require.Contains(t, html, "No captions available", "honest terminal copy")
require.NotContains(t, html, "hx-trigger", "terminal card must stop polling")
}