From 25215cbcbd14323d2fc1946700171b7a40beb6b3 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Wed, 3 Jun 2026 19:48:52 +0200
Subject: [PATCH] feat(web): immediate summarize + status poll + ASCII tapir
spinner
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Clicking "Summarize" now runs the summary in the background (when a Processor is
wired) instead of only queuing it. The handler flips the DB flag, kicks off the
work on a detached context, and returns an animated "processing" card that polls
GET /v/{id}/status every 2s via HTMX. Status returns the summary card once it
lands (no poll → polling stops), the animation while in-flight, or the Queued
card otherwise. Queue-only behaviour is unchanged when no Processor is set.
The spinner is a CSS-only cross-fade of three ASCII tapir frames — no JS, honours
prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
internal/web/handlers.go | 62 ++-
internal/web/processing_handler_test.go | 129 ++++++
internal/web/view.go | 40 ++
internal/web/views.templ | 33 ++
internal/web/views_templ.go | 553 ++++++++++++++++--------
5 files changed, 622 insertions(+), 195 deletions(-)
create mode 100644 internal/web/processing_handler_test.go
diff --git a/internal/web/handlers.go b/internal/web/handlers.go
index cd61b0f..0614a22 100644
--- a/internal/web/handlers.go
+++ b/internal/web/handlers.go
@@ -91,6 +91,7 @@ func (a *App) Router() http.Handler {
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
+ app.HandleFunc("GET /v/{videoId}/status", a.handleStatus)
app.HandleFunc("GET /register", a.handleRegisterForm)
app.HandleFunc("POST /register", a.handleRegister)
@@ -215,10 +216,12 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
}
-// handleRequestSummarize queues a video for manual summarization. It does NOT run
-// the engine inline — it only flips summarize_requested; the next `tapir run`
-// picks it up (the single summarization driver). For HTMX it returns the refreshed
-// card (now showing "Queued"); without JS it redirects back to the list.
+// handleRequestSummarize handles the "Summarize" button. It always flips the DB
+// flag (summarize_requested) so the work is durable. With a Processor wired it
+// then summarizes immediately in the background and answers with the animated
+// processing card that polls /status until done; without one (queue-only) it
+// answers with the "Queued" card — the next `tapir run` does the work. Without
+// JS it redirects back to the list (POST→redirect→GET).
func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
@@ -245,9 +248,60 @@ func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
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.
+// 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
+// error the DB flag stays set, so the video remains queued for the next
+// `tapir run`; a successful Processor.ProcessVideo clears it itself.
+func (a *App) startProcessing(userID, videoID string) {
+ key := processingKey(userID, videoID)
+ a.Processing.Add(key)
+ go func() {
+ defer a.Processing.Remove(key)
+ if err := a.Processor.ProcessVideo(context.Background(), userID, videoID); err != nil {
+ a.logger().Error("background summarize", "user", userID, "video", videoID, "err", err)
+ }
+ }()
+}
+
+// handleStatus is the HTMX poll target for an in-flight summarization. It returns
+// the card in its current state: the full summary card once the summary exists,
+// otherwise the animated processing card while still in-flight (which keeps
+// polling), or the queued/button card when neither holds. VideoCard carries no
+// polling attributes, so HTMX stops polling once it swaps in.
+func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) {
+ userID, ok := a.currentUserID(w, r)
+ if !ok {
+ return
+ }
+ videoID := r.PathValue("videoId")
+
+ row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
+ if errors.Is(err, store.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ a.serverError(w, r, "get video", err)
+ return
+ }
+
+ if row.Summarized || !a.Processing.Has(processingKey(userID, videoID)) {
+ a.render(w, r, VideoCard(*row))
+ return
+ }
+ a.render(w, r, processingCard(*row))
+}
+
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
// submits the desired new value (enabled=true|false). For HTMX it returns the
// refreshed mode control; without JS it redirects back to the account page.
diff --git a/internal/web/processing_handler_test.go b/internal/web/processing_handler_test.go
new file mode 100644
index 0000000..0b561af
--- /dev/null
+++ b/internal/web/processing_handler_test.go
@@ -0,0 +1,129 @@
+package web_test
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "gitea.d-ma.be/mathias/tapir/internal/web"
+)
+
+// fakeProcessor records ProcessVideo calls. With block set it parks until the
+// channel is closed, so a test can observe the handler return before the
+// background work finishes (proving it ran in a goroutine).
+type fakeProcessor struct {
+ block chan struct{}
+ done chan struct{}
+ calls []string
+}
+
+func (f *fakeProcessor) ProcessVideo(_ context.Context, _, videoID string) error {
+ if f.block != nil {
+ <-f.block
+ }
+ f.calls = append(f.calls, videoID)
+ if f.done != nil {
+ close(f.done)
+ }
+ return nil
+}
+
+func TestRequestSummarizeImmediateProcessing(t *testing.T) {
+ app := newApp(t)
+ p := rawPool(t)
+ resetDB(t, p)
+ seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
+
+ fp := &fakeProcessor{block: make(chan struct{}), done: make(chan struct{})}
+ app.Processor = fp
+
+ rec := postSummarize(t, app, videoX, true)
+ require.Equal(t, http.StatusOK, rec.Code)
+ html := body(t, rec)
+
+ // The processing card came back while ProcessVideo is still parked on block:
+ // the work runs in a goroutine, the handler did not wait for it.
+ require.Contains(t, html, "Summarizing", "processing card returned")
+ require.Contains(t, html, "ω", "ascii tapir frame rendered")
+ require.Contains(t, html, "∪∪", "all tapir frames rendered")
+ require.Contains(t, html, "/v/"+videoX+"/status", "card polls the status endpoint")
+ require.Contains(t, html, `hx-trigger="every 2s"`, "card auto-polls every 2s")
+ require.NotContains(t, html, "Queued", "not the queue-only card")
+
+ close(fp.block)
+ select {
+ case <-fp.done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("ProcessVideo was not called in the background")
+ }
+ require.Equal(t, []string{videoX}, fp.calls)
+}
+
+func TestStatusProcessingThenDone(t *testing.T) {
+ ctx := context.Background()
+ app := newApp(t)
+ p := rawPool(t)
+ resetDB(t, p)
+ seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
+
+ // Park ProcessVideo so the video stays in-flight while we poll status.
+ fp := &fakeProcessor{block: make(chan struct{})}
+ app.Processor = fp
+ require.Equal(t, http.StatusOK, postSummarize(t, app, videoX, true).Code)
+
+ // Processing: status returns the animation card, still polling.
+ rec := getStatus(t, app, videoX)
+ require.Equal(t, http.StatusOK, rec.Code)
+ html := body(t, rec)
+ require.Contains(t, html, "Summarizing", "in-flight → animation card")
+ require.Contains(t, html, `hx-trigger="every 2s"`, "still polling")
+
+ close(fp.block)
+
+ // Done: once a summary exists, status returns the summary card with no poll.
+ require.NoError(t, deliver(ctx, app, videoX, "the summary body"))
+ rec = getStatus(t, app, videoX)
+ require.Equal(t, http.StatusOK, rec.Code)
+ html = body(t, rec)
+ require.NotContains(t, html, "Summarizing", "done → no animation")
+ require.NotContains(t, html, "every 2s", "done card does not poll (polling stops)")
+ require.Contains(t, html, "/v/"+videoX+"\"", "links to the detail page")
+}
+
+func TestStatusQueuedWhenNotInFlight(t *testing.T) {
+ ctx := context.Background()
+ app := newApp(t)
+ p := rawPool(t)
+ resetDB(t, p)
+ seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
+
+ // Flag set but nothing in-flight (e.g. queue-only, or after a restart).
+ require.NoError(t, app.Store.RequestSummarize(ctx, userID, videoX))
+
+ rec := getStatus(t, app, videoX)
+ require.Equal(t, http.StatusOK, rec.Code)
+ html := body(t, rec)
+ require.Contains(t, html, "Queued", "queued chip card")
+ require.NotContains(t, html, "Summarizing", "not processing")
+ require.NotContains(t, html, "every 2s", "queued card does not poll")
+}
+
+func TestStatusNotFound(t *testing.T) {
+ app := newApp(t)
+ resetDB(t, rawPool(t))
+ rec := getStatus(t, app, videoX)
+ require.Equal(t, http.StatusNotFound, rec.Code)
+}
+
+func getStatus(t *testing.T, app *web.App, videoID string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, "/v/"+videoID+"/status", nil)
+ req.Header.Set("HX-Request", "true")
+ rec := httptest.NewRecorder()
+ app.Router().ServeHTTP(rec, req)
+ return rec
+}
diff --git a/internal/web/view.go b/internal/web/view.go
index 1314561..7bd5f63 100644
--- a/internal/web/view.go
+++ b/internal/web/view.go
@@ -176,6 +176,30 @@ func summarizeURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/summarize")
}
+// statusURL builds the processing-status poll path (GET) for a video id — the
+// HTMX poll target while an immediate summarization is in flight.
+func statusURL(videoID string) templ.SafeURL {
+ return templ.SafeURL("/v/" + videoID + "/status")
+}
+
+// tapirFrame1..3 are the ASCII tapir frames cycled by the TapirSpinner CSS
+// animation. Three near-identical frames (snout and feet shift) read as a stocky,
+// big-snouted tapir ambling in place while a summary is generated.
+const (
+ tapirFrame1 = ` /\_____/\
+( · ω · )
+ ) ∩∩ (
+(__( )__)`
+ tapirFrame2 = ` /\_____/\
+( · ω · )
+ ) ∩∩ (
+ (__( ^ )__)`
+ tapirFrame3 = ` /\_____/\
+( · ω · )
+ ) ∪∪ (
+(__( )__)`
+)
+
// summarizeModeLabel names the current mode for display.
func summarizeModeLabel(auto bool) string {
if auto {
@@ -381,6 +405,22 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.card-pending { border-style: dashed; }
.card-pending .card-title { color: var(--muted); font-weight: 600; }
+/* summarizing animation — a little ASCII tapir ambling in place. Three frames
+ are stacked absolutely and cross-faded by a stepped keyframe with staggered
+ delays, so exactly one shows at a time (no JS). */
+.card-processing { border-style: dashed; }
+.tapir-spin { position: relative; height: 5.2em; margin: var(--s2) 0; }
+.tapir-spin pre { position: absolute; inset: 0; margin: 0; opacity: 0; font: .9rem/1.15 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--accent); animation: tapir-cycle 1.2s steps(1, end) infinite; }
+.tapir-spin pre:nth-child(1) { animation-delay: 0s; }
+.tapir-spin pre:nth-child(2) { animation-delay: .4s; }
+.tapir-spin pre:nth-child(3) { animation-delay: .8s; }
+@keyframes tapir-cycle { 0%, 33.32% { opacity: 1; } 33.33%, 100% { opacity: 0; } }
+.tapir-label { color: var(--muted); font-size: .9rem; margin: 0; }
+@media (prefers-reduced-motion: reduce) {
+ .tapir-spin pre { animation: none; }
+ .tapir-spin pre:nth-child(1) { opacity: 1; }
+}
+
/* summarization mode toggle on the account page */
.summarize-mode { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; }
.summarize-mode p { margin: 0; }
diff --git a/internal/web/views.templ b/internal/web/views.templ
index 493e6e5..7ce9ad9 100644
--- a/internal/web/views.templ
+++ b/internal/web/views.templ
@@ -140,6 +140,39 @@ templ VideoCard(r store.SummaryRow) {
}
+// TapirSpinner is the summarizing animation: three ASCII tapir frames stacked
+// and cross-faded by CSS keyframes (no JS), with a muted "Summarizing…" label.
+// The art is aria-hidden — it is decorative; the label carries the meaning.
+templ TapirSpinner() {
+
+
{ tapirFrame1 }
+
{ tapirFrame2 }
+
{ tapirFrame3 }
+
+
Summarizing…
+}
+
+// processingCard is the in-flight summarization card. It replaces the Summarize
+// button card and polls /v/{id}/status every 2s, swapping itself (outerHTML, same
+// id as VideoCard) for whatever state comes back: it keeps polling while still
+// processing, and the summary/queued card it is eventually replaced by carries no
+// poll, so polling stops on its own when the fragment changes.
+templ processingCard(r store.SummaryRow) {
+
+
{ displayTitle(r) }
+ if cardMeta(r) != "" {
+
{ cardMeta(r) }
+ }
+ @TapirSpinner()
+
+}
+
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
// and the action button group.
templ DetailPage(r store.SummaryRow) {
diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go
index fe4481c..5641174 100644
--- a/internal/web/views_templ.go
+++ b/internal/web/views_templ.go
@@ -595,9 +595,10 @@ func VideoCard(r store.SummaryRow) templ.Component {
})
}
-// DetailPage is the full summary view: text, highlights, takeaways, metadata,
-// and the action button group.
-func DetailPage(r store.SummaryRow) templ.Component {
+// TapirSpinner is the summarizing animation: three ASCII tapir frames stacked
+// and cross-faded by CSS keyframes (no JS), with a muted "Summarizing…" label.
+// The art is aria-hidden — it is decorative; the label carries the meaning.
+func TapirSpinner() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -618,7 +619,177 @@ func DetailPage(r store.SummaryRow) templ.Component {
templ_7745c5c3_Var28 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Var29 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
Automatic summarizes every new video as it is discovered. Manual lets you pick which videos to summarize — new videos appear in your list with a Summarize button.
Automatic summarizes every new video as it is discovered. Manual lets you pick which videos to summarize — new videos appear in your list with a Summarize button.