feat(web): unify card states — one 'Summarize now' verb, honest no-captions state
Five explicit footer states, status-primary:
1. Summarized — chip + actions, no button (unchanged)
2. No captions (TranscriptStatus=="none") — NEW: 'No transcript available' muted
text, no button, no POST URL. Removes the dead-end 'Summarize' button that
tried and failed when there were no captions to fetch.
3. Queued (SummarizeRequested) — chip + muted text, no button (unchanged)
4. Rate-limited — 'Fetching soon…' + quiet 'Summarize now' → /retry-now
5. Pending — 'Not summarized' + quiet 'Summarize now' → /summarize
One verb ('Summarize now'), one quiet style (.btn-quiet, renamed from .btn-retry
which was state-specific). User doesn't see the internal pipeline distinction;
both buttons post to their existing handlers unchanged. Form class renamed
card-nudge-form. Dropped engineer-facing tooltip; user-facing hint added.
'Try now' wording removed entirely.
This commit is contained in:
@@ -17,9 +17,75 @@ func renderVideoCard(t *testing.T, r store.SummaryRow) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// A rate-limited, unsummarized video shows an active "Try now" button so the user
|
// noActionButton asserts the rendered HTML contains no form POST (no action URL
|
||||||
// can manually trigger an immediate fetch through the shared rate gate.
|
// of the form /v/.../summarize or /v/.../retry-now) and no btn-quiet button.
|
||||||
func TestVideoCard_RateLimitedShowsRetryingBadge(t *testing.T) {
|
func noActionButton(t *testing.T, html, state string) {
|
||||||
|
t.Helper()
|
||||||
|
if strings.Contains(html, "/summarize") {
|
||||||
|
t.Errorf("state %q: expected no summarize URL, got:\n%s", state, html)
|
||||||
|
}
|
||||||
|
if strings.Contains(html, "/retry-now") {
|
||||||
|
t.Errorf("state %q: expected no retry-now URL, got:\n%s", state, html)
|
||||||
|
}
|
||||||
|
if strings.Contains(html, "btn-quiet") {
|
||||||
|
t.Errorf("state %q: expected no action button, got:\n%s", state, html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVideoCard_State1_Summarized — chip + no nudge button.
|
||||||
|
func TestVideoCard_State1_Summarized(t *testing.T) {
|
||||||
|
html := renderVideoCard(t, store.SummaryRow{
|
||||||
|
VideoID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||||
|
Title: "Done Video",
|
||||||
|
Summarized: true,
|
||||||
|
Summary: "A great talk about Go.",
|
||||||
|
AIProvider: "local",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(html, "local") {
|
||||||
|
t.Errorf("state 1: expected AI provider chip, got:\n%s", html)
|
||||||
|
}
|
||||||
|
noActionButton(t, html, "summarized")
|
||||||
|
if strings.Contains(html, "Summarize now") {
|
||||||
|
t.Errorf("state 1: no nudge button on a summarized card, got:\n%s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVideoCard_State2_NoTranscript — terminal; muted status, NO button, NO POST URL.
|
||||||
|
func TestVideoCard_State2_NoTranscript(t *testing.T) {
|
||||||
|
html := renderVideoCard(t, store.SummaryRow{
|
||||||
|
VideoID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||||
|
Title: "Silent Video",
|
||||||
|
Summarized: false,
|
||||||
|
TranscriptStatus: "none",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(html, "No transcript available") {
|
||||||
|
t.Errorf("state 2: expected 'No transcript available' text, got:\n%s", html)
|
||||||
|
}
|
||||||
|
noActionButton(t, html, "none-transcript")
|
||||||
|
if strings.Contains(html, "Summarize now") {
|
||||||
|
t.Errorf("state 2: no nudge button when there are no captions, got:\n%s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVideoCard_State3_Queued — "Queued" chip, no button.
|
||||||
|
func TestVideoCard_State3_Queued(t *testing.T) {
|
||||||
|
html := renderVideoCard(t, store.SummaryRow{
|
||||||
|
VideoID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||||
|
Title: "Queued Video",
|
||||||
|
Summarized: false,
|
||||||
|
SummarizeRequested: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(html, "Queued") {
|
||||||
|
t.Errorf("state 3: expected 'Queued' chip, got:\n%s", html)
|
||||||
|
}
|
||||||
|
noActionButton(t, html, "queued")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVideoCard_State4_RateLimited — quiet status + "Summarize now" → retry-now URL.
|
||||||
|
func TestVideoCard_State4_RateLimited(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",
|
||||||
Title: "Throttled Video",
|
Title: "Throttled Video",
|
||||||
@@ -27,29 +93,61 @@ func TestVideoCard_RateLimitedShowsRetryingBadge(t *testing.T) {
|
|||||||
TranscriptStatus: "rate_limited",
|
TranscriptStatus: "rate_limited",
|
||||||
})
|
})
|
||||||
|
|
||||||
if !strings.Contains(html, "Try now") {
|
if !strings.Contains(html, "Fetching soon") {
|
||||||
t.Errorf("expected a 'Try now' button, got:\n%s", html)
|
t.Errorf("state 4: expected 'Fetching soon' status text, got:\n%s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "Summarize now") {
|
||||||
|
t.Errorf("state 4: expected 'Summarize now' button, got:\n%s", html)
|
||||||
}
|
}
|
||||||
if !strings.Contains(html, "retry-now") {
|
if !strings.Contains(html, "retry-now") {
|
||||||
t.Errorf("expected the retry-now route in the form action, got:\n%s", html)
|
t.Errorf("state 4: expected retry-now URL in 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("state 4: rate-limited card must not POST to /summarize, got:\n%s", html)
|
||||||
|
}
|
||||||
|
if strings.Contains(html, "Try now") {
|
||||||
|
t.Errorf("state 4: 'Try now' verb must not appear, got:\n%s", html)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An ordinary unsummarized video still offers the Summarize button.
|
// TestVideoCard_State5_Pending — quiet status + "Summarize now" → summarize URL.
|
||||||
func TestVideoCard_UnsummarizedShowsSummarize(t *testing.T) {
|
func TestVideoCard_State5_Pending(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",
|
||||||
Title: "Fresh Video",
|
Title: "Fresh Video",
|
||||||
Summarized: false,
|
Summarized: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
if !strings.Contains(html, ">Summarize<") {
|
if !strings.Contains(html, "Not summarized") {
|
||||||
t.Errorf("expected a Summarize button, got:\n%s", html)
|
t.Errorf("state 5: expected 'Not summarized' status text, got:\n%s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "Summarize now") {
|
||||||
|
t.Errorf("state 5: expected 'Summarize now' button, got:\n%s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "/summarize") {
|
||||||
|
t.Errorf("state 5: expected summarize URL in form action, got:\n%s", html)
|
||||||
|
}
|
||||||
|
if strings.Contains(html, "retry-now") {
|
||||||
|
t.Errorf("state 5: pending card must not POST to /retry-now, got:\n%s", html)
|
||||||
|
}
|
||||||
|
if strings.Contains(html, "Try now") {
|
||||||
|
t.Errorf("state 5: 'Try now' verb must not appear, got:\n%s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVideoCard_TryNowAbsent — "Try now" must not appear in any rendered card.
|
||||||
|
func TestVideoCard_TryNowAbsent(t *testing.T) {
|
||||||
|
cases := []store.SummaryRow{
|
||||||
|
{VideoID: "a", Summarized: true, Summary: "s", AIProvider: "local"},
|
||||||
|
{VideoID: "b", TranscriptStatus: "none"},
|
||||||
|
{VideoID: "c", SummarizeRequested: true},
|
||||||
|
{VideoID: "d", TranscriptStatus: "rate_limited"},
|
||||||
|
{VideoID: "e"},
|
||||||
|
}
|
||||||
|
for _, r := range cases {
|
||||||
|
html := renderVideoCard(t, r)
|
||||||
|
if strings.Contains(html, "Try now") {
|
||||||
|
t.Errorf("'Try now' must not appear in any card state; video %q got:\n%s", r.VideoID, html)
|
||||||
}
|
}
|
||||||
if strings.Contains(html, "Retrying later") {
|
|
||||||
t.Errorf("no retry badge for a non-rate-limited video, got:\n%s", html)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -544,9 +544,9 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
|
|||||||
.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 { 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 { display: flex; align-items: center; gap: var(--s1); }
|
||||||
.pipeline-bar span + span::before { content: "·"; margin-right: var(--s1); }
|
.pipeline-bar span + span::before { content: "·"; margin-right: var(--s1); }
|
||||||
.retry-form { display: inline; }
|
.card-nudge-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-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-retry:hover { background: var(--accent-weak); }
|
.btn-quiet: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; }
|
||||||
|
|||||||
+25
-11
@@ -182,11 +182,15 @@ templ summaryList(rows []store.SummaryRow, hasConnected bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// VideoCard is one list card, also returned standalone by POST /v/{id}/summarize
|
// VideoCard is one list card, returned standalone by POST /v/{id}/summarize and
|
||||||
// (HTMX swaps it in place via outerHTML). A summarized video links to its detail
|
// /v/{id}/retry-now (HTMX swaps outerHTML). Five footer states, status-primary:
|
||||||
// page and shows its provider chip / fallback badge / action state. An
|
// 1. Summarized — preview + chip + actions; no button.
|
||||||
// unsummarized video gets a muted "pending" treatment and either a "Summarize"
|
// 2. No captions (TranscriptStatus=="none") — terminal; "No transcript available"; no button.
|
||||||
// button (to queue it) or a "Queued" chip when already requested.
|
// 3. Queued (SummarizeRequested) — "Queued · waiting for the next run"; no button.
|
||||||
|
// 4. Rate-limited — "Fetching soon" + quiet "Summarize now" → /retry-now.
|
||||||
|
// 5. Pending (else) — "Not summarized" + quiet "Summarize now" → /summarize.
|
||||||
|
// States 4 and 5 use one verb ("Summarize now") and one style (.btn-quiet); the
|
||||||
|
// backend side-effect difference (clear-backoff vs. set-flag) is invisible to users.
|
||||||
templ VideoCard(r store.SummaryRow) {
|
templ VideoCard(r store.SummaryRow) {
|
||||||
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
|
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
|
||||||
if r.Summarized {
|
if r.Summarized {
|
||||||
@@ -204,6 +208,7 @@ templ VideoCard(r store.SummaryRow) {
|
|||||||
}
|
}
|
||||||
<div class="card-foot">
|
<div class="card-foot">
|
||||||
if r.Summarized {
|
if r.Summarized {
|
||||||
|
// State 1: summarized — provider chip, fallback badge, action state.
|
||||||
if r.AIProvider != "" {
|
if r.AIProvider != "" {
|
||||||
<span class="chip">{ r.AIProvider }</span>
|
<span class="chip">{ r.AIProvider }</span>
|
||||||
}
|
}
|
||||||
@@ -213,29 +218,38 @@ templ VideoCard(r store.SummaryRow) {
|
|||||||
if len(r.Actions) > 0 {
|
if len(r.Actions) > 0 {
|
||||||
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
|
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
|
||||||
}
|
}
|
||||||
|
} else if r.TranscriptStatus == "none" {
|
||||||
|
// State 2: no captions — terminal dead-end; nothing the user can do.
|
||||||
|
<span class="card-state muted">No transcript available</span>
|
||||||
|
} else if r.SummarizeRequested {
|
||||||
|
// State 3: queued for the next scheduled pass.
|
||||||
|
<span class="chip">Queued</span>
|
||||||
|
<span class="card-state muted">waiting for the next run</span>
|
||||||
} else if r.TranscriptStatus == "rate_limited" {
|
} else if r.TranscriptStatus == "rate_limited" {
|
||||||
|
// State 4: rate-limited — quiet status + quiet nudge button → retry-now handler.
|
||||||
|
<span class="card-state muted">Fetching soon…</span>
|
||||||
<form
|
<form
|
||||||
method="post"
|
method="post"
|
||||||
action={ retryNowURL(r.VideoID) }
|
action={ retryNowURL(r.VideoID) }
|
||||||
hx-post={ string(retryNowURL(r.VideoID)) }
|
hx-post={ string(retryNowURL(r.VideoID)) }
|
||||||
hx-target={ "#video-" + r.VideoID }
|
hx-target={ "#video-" + r.VideoID }
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
class="retry-form"
|
class="card-nudge-form"
|
||||||
>
|
>
|
||||||
<button type="submit" class="btn-retry" title="Fetch transcript now through the shared rate gate">Try now</button>
|
<button type="submit" class="btn-quiet" title="Summarize this one now">Summarize now</button>
|
||||||
</form>
|
</form>
|
||||||
} else if r.SummarizeRequested {
|
|
||||||
<span class="chip">Queued</span>
|
|
||||||
<span class="card-state muted">waiting for the next run</span>
|
|
||||||
} else {
|
} else {
|
||||||
|
// State 5: pending — discovered, not yet attempted; nudge button → summarize handler.
|
||||||
|
<span class="card-state muted">Not summarized</span>
|
||||||
<form
|
<form
|
||||||
method="post"
|
method="post"
|
||||||
action={ summarizeURL(r.VideoID) }
|
action={ summarizeURL(r.VideoID) }
|
||||||
hx-post={ string(summarizeURL(r.VideoID)) }
|
hx-post={ string(summarizeURL(r.VideoID)) }
|
||||||
hx-target={ "#video-" + r.VideoID }
|
hx-target={ "#video-" + r.VideoID }
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
|
class="card-nudge-form"
|
||||||
>
|
>
|
||||||
<button type="submit" class="btn-secondary">Summarize</button>
|
<button type="submit" class="btn-quiet" title="Summarize this one now">Summarize now</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+215
-201
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user