Items-list sparklines, retro CSS, pinned tooling, deploy docs
- Bulk-load recent price points per item and render a sparkline in the items list (new LoadRecentPriceHistory query avoids N+1). - Add retro.css visual layer and refreshed login/items/layout styling. - Swap the logo from webp to avif. - Pin htmx/Chart.js/Tailwind/templ versions in the Makefile with vendor / tools / update-deps targets; README documents the dependency-bump flow and the hardened systemd deploy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -798,6 +798,49 @@ func (s *Store) InsertPricePoint(ctx context.Context, p *models.PricePoint) erro
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadRecentPriceHistory bulk-loads the last `perItem` price points (oldest →
|
||||
// newest) for each item id. Returns a map keyed by item id; items with no
|
||||
// history are absent from the map. Used by the items list page to render a
|
||||
// sparkline per row without N+1 queries.
|
||||
func (s *Store) LoadRecentPriceHistory(ctx context.Context, ids []int64, perItem int) (map[int64][]models.PricePoint, error) {
|
||||
out := make(map[int64][]models.PricePoint, len(ids))
|
||||
if len(ids) == 0 || perItem <= 0 {
|
||||
return out, nil
|
||||
}
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
args = append(args, perItem)
|
||||
q := `
|
||||
WITH ranked AS (
|
||||
SELECT item_id, price, store, polled_at,
|
||||
ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY polled_at DESC) AS rn
|
||||
FROM price_history
|
||||
WHERE item_id IN (` + strings.Join(placeholders, ",") + `)
|
||||
)
|
||||
SELECT item_id, price, store, polled_at FROM ranked WHERE rn <= ?
|
||||
ORDER BY item_id, polled_at ASC
|
||||
`
|
||||
rows, err := s.DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var p models.PricePoint
|
||||
var store sql.NullString
|
||||
if err := rows.Scan(&p.ItemID, &p.Price, &store, &p.PolledAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Store = s.dec(store.String)
|
||||
out[p.ItemID] = append(out[p.ItemID], p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListPriceHistory(ctx context.Context, itemID int64) ([]models.PricePoint, error) {
|
||||
rows, err := s.DB.QueryContext(ctx,
|
||||
`SELECT id, item_id, price, store, polled_at FROM price_history WHERE item_id = ? ORDER BY polled_at ASC`,
|
||||
|
||||
@@ -30,11 +30,22 @@ func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
cats, _ := a.Store.ListCategories(r.Context())
|
||||
|
||||
// Bulk-load recent price history so each row can render a sparkline
|
||||
// without N+1 queries. 20 points is enough for a meaningful trend line
|
||||
// at 80px wide and stays cheap on the largest realistic watchlists.
|
||||
ids := make([]int64, 0, len(items))
|
||||
for _, it := range items {
|
||||
ids = append(ids, it.ID)
|
||||
}
|
||||
history, _ := a.Store.LoadRecentPriceHistory(r.Context(), ids, 20)
|
||||
|
||||
render(w, r, templates.Items(templates.ItemsData{
|
||||
Page: a.page(r, "Items", "items"),
|
||||
Items: items,
|
||||
Categories: cats,
|
||||
SelectedCategory: cat,
|
||||
PriceHistory: history,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -387,7 +398,8 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
a.Scheduler.SyncItem(*it)
|
||||
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context())))
|
||||
hist, _ := a.Store.LoadRecentPriceHistory(r.Context(), []int64{id}, 20)
|
||||
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context()), hist[id]))
|
||||
}
|
||||
|
||||
func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user