Fix bugs found in local testing

- Dashboard auto-refresh rendered the full layout into its own
  refresh container, producing a duplicate sidebar every 60s; it now
  renders only the body partial.
- 'Run Now' runs synchronously with a bounded timeout and returns
  refreshed results plus success/error feedback, instead of
  firing-and-forgetting with no signal.
- Price-history chart data moved from a <script> block to a data-
  attribute: templ does not interpolate expressions inside <script>
  element content, so the JSON was emitted literally.
- The htmx indicator spinner was permanently visible due to CSS
  source order; the indicator rules now follow .v-spinner.

Also refreshes README for this session's changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-14 12:11:07 -07:00
parent 08ff1695e0
commit d87536c879
12 changed files with 550 additions and 366 deletions

View File

@@ -24,10 +24,11 @@ func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Render only the inner block by reusing the full body component; the
// outer hx-swap="outerHTML" replaces the same wrapper. The full Dashboard
// template is overkill but keeps a single source of truth.
render(w, r, templates.Dashboard(d))
// Render ONLY the inner body. The hx-swap="outerHTML" on DashboardBody's
// root div replaces it with this fresh copy. Rendering templates.Dashboard
// here would return the whole Layout — sidebar included — nested inside
// the div, producing a duplicate nav bar on every refresh.
render(w, r, templates.DashboardBody(d))
}
func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {

View File

@@ -2,13 +2,13 @@ package handlers
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"time"
"veola/internal/apify"
"veola/internal/models"
@@ -261,46 +261,16 @@ func (a *App) runPreview(ctx context.Context, it models.Item) ([]apify.UnifiedRe
var merged []apify.UnifiedResult
primarySource := ""
for _, p := range plans {
actorID := p.ActorID()
if actorID == "" {
continue
}
raw, err := a.Apify.Run(ctx, actorID, p.Input())
decoded, err := a.Scheduler.ExecutePlan(ctx, p)
if err != nil {
slog.Warn("preview run failed", "actor", actorID, "query", p.Query(), "err", err)
slog.Warn("preview plan failed",
"provider", p.Provider(),
"marketplace", p.Marketplace(),
"query", p.Query(),
"err", err,
)
continue
}
decoded, _ := apify.Decode(raw, p.Source())
for i := range decoded {
decoded[i].MatchedQuery = p.Query()
}
usable := 0
for _, r := range decoded {
if r.URL != "" && r.Price > 0 {
usable++
}
}
slog.Info("preview decoded",
"marketplace", previewMarket,
"actor", actorID,
"query", p.Query(),
"raw", len(raw),
"decoded", len(decoded),
"usable", usable,
)
if usable == 0 && len(raw) > 0 {
var sample map[string]any
if err := json.Unmarshal(raw[0], &sample); err == nil {
ks := make([]string, 0, len(sample))
for k := range sample {
ks = append(ks, k)
}
slog.Warn("preview decoded zero usable rows; raw item keys",
"actor", actorID,
"keys", ks,
)
}
}
merged = append(merged, decoded...)
if primarySource == "" {
primarySource = p.Source()
@@ -431,9 +401,40 @@ func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
go a.Scheduler.RunPoll(context.Background(), *it)
// Re-render the row immediately so HTMX has something to swap in.
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context())))
// Run synchronously so the response reflects the finished poll. Bounded so
// a slow Apify actor run can't tie the request up indefinitely (eBay
// Browse API polls finish in seconds, well within this). Detached from the
// request context so a client disconnect mid-run doesn't abort DB writes.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
a.Scheduler.RunPoll(ctx, *it)
// RunPoll writes best price, last_polled_at, and last_poll_error; re-fetch
// so the rendered partial shows the post-poll state.
fresh, err := a.Store.GetItem(r.Context(), id)
if err != nil || fresh == nil {
http.Error(w, "could not reload item after run", http.StatusInternalServerError)
return
}
// The results page asks for a refreshed listing table; the items list
// asks for a refreshed row. Both POST to this same endpoint.
if r.PostFormValue("from") == "results" {
d, err := a.buildItemResultsData(r, fresh, 1, "found_desc")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if fresh.LastPollError != "" {
d.RunError = "Run finished with errors: " + fresh.LastPollError
} else {
d.RunMsg = fmt.Sprintf("Run complete. Showing %d listing(s).", len(d.Results))
}
render(w, r, templates.ItemResultsTable(d))
return
}
render(w, r, templates.ItemRow(*fresh, a.Auth.CSRFToken(r.Context())))
}
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {

View File

@@ -21,20 +21,29 @@ func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
d, err := a.buildItemResultsData(r, it, page, r.URL.Query().Get("order"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, r, templates.ItemResults(d))
}
order := r.URL.Query().Get("order")
// buildItemResultsData assembles the per-item results view: paginated results,
// price history, badge, and chart JSON. Shared by GetItemResults and the
// "Run Now" handler so both render identical data.
func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, order string) (templates.ItemResultsData, error) {
if order == "" {
order = "found_desc"
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
total, err := a.Store.CountResults(r.Context(), id)
total, err := a.Store.CountResults(r.Context(), it.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
return templates.ItemResultsData{}, err
}
totalPages := (total + resultsPerPage - 1) / resultsPerPage
if totalPages < 1 {
@@ -45,36 +54,31 @@ func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) {
}
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
ItemID: id,
ItemID: it.ID,
Limit: resultsPerPage,
Offset: (page - 1) * resultsPerPage,
Order: order,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
return templates.ItemResultsData{}, err
}
history, err := a.Store.ListPriceHistory(r.Context(), id)
history, err := a.Store.ListPriceHistory(r.Context(), it.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
return templates.ItemResultsData{}, err
}
badge := scheduler.PickBadge(*it, history, time.Now())
chart := buildChartJSON(history)
render(w, r, templates.ItemResults(templates.ItemResultsData{
return templates.ItemResultsData{
Page: a.page(r, it.Name, "items"),
Item: *it,
Badge: badge,
Badge: scheduler.PickBadge(*it, history, time.Now()),
History: history,
Results: results,
Page_: page,
TotalPages: totalPages,
Order: order,
HistoryChartJSON: chart,
}))
HistoryChartJSON: buildChartJSON(history),
}, nil
}
func buildChartJSON(history []models.PricePoint) string {