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:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user