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:
prosolis
2026-05-15 19:10:56 -07:00
parent 0ec97afafb
commit ea3577a45e
17 changed files with 1174 additions and 343 deletions

View File

@@ -2,6 +2,8 @@ package templates
import (
"fmt"
"math"
"strings"
"veola/internal/models"
)
@@ -11,6 +13,85 @@ type ItemsData struct {
Items []models.Item
Categories []string
SelectedCategory string
// PriceHistory holds the last ~20 points per item id, ascending in time.
// Nil/missing entries render an empty sparkline cell.
PriceHistory map[int64][]models.PricePoint
}
// sparklinePoints turns a price-history slice into an SVG polyline "points"
// attribute, normalized to a 80x24 viewBox with 2px padding so endpoints
// don't clip the stroke. Returns "" when there isn't enough history to draw.
func sparklinePoints(history []models.PricePoint) string {
if len(history) < 2 {
return ""
}
const w, h, pad = 80.0, 24.0, 2.0
minP, maxP := math.Inf(1), math.Inf(-1)
for _, p := range history {
if p.Price < minP {
minP = p.Price
}
if p.Price > maxP {
maxP = p.Price
}
}
span := maxP - minP
if span == 0 {
// All-equal series: draw a flat line through the middle.
span = 1
}
step := (w - 2*pad) / float64(len(history)-1)
parts := make([]string, len(history))
for i, p := range history {
x := pad + float64(i)*step
y := h - pad - ((p.Price-minP)/span)*(h-2*pad)
parts[i] = fmt.Sprintf("%.1f,%.1f", x, y)
}
return strings.Join(parts, " ")
}
// sparklineTrendClass compares the last point to the average of the prior
// points and returns a CSS class so the sparkline tints green (price down),
// red (up), or neutral. Threshold is ±3% to ignore tiny wobbles.
func sparklineTrendClass(history []models.PricePoint) string {
if len(history) < 2 {
return ""
}
last := history[len(history)-1].Price
var sum float64
for _, p := range history[:len(history)-1] {
sum += p.Price
}
avg := sum / float64(len(history)-1)
if avg == 0 {
return "v-spark-flat"
}
switch {
case last <= avg*0.97:
return "v-spark-down"
case last >= avg*1.03:
return "v-spark-up"
}
return "v-spark-flat"
}
// trendArrow returns the unicode arrow + a CSS class describing the direction.
// Same threshold logic as the sparkline. Empty when there's no trend signal.
func trendArrow(history []models.PricePoint) (glyph, class string) {
switch sparklineTrendClass(history) {
case "v-spark-down":
return "↓", "v-trend-down"
case "v-spark-up":
return "↑", "v-trend-up"
case "v-spark-flat":
return "→", "v-trend-flat"
}
return "", ""
}
// isDeal is the gate for the mascot "deal" moment on a row.
func isDeal(it models.Item) bool {
return it.BestPrice != nil && it.TargetPrice != nil && *it.BestPrice <= *it.TargetPrice
}
templ itemsBody(d ItemsData) {
@@ -41,6 +122,7 @@ templ itemsBody(d ItemsData) {
<th>Category</th>
<th>Target</th>
<th>Best Price</th>
<th>Trend</th>
<th>Last Polled</th>
<th>Status</th>
<th></th>
@@ -48,7 +130,7 @@ templ itemsBody(d ItemsData) {
</thead>
<tbody id="items-tbody">
for _, it := range d.Items {
@itemRow(it, d.CSRFToken)
@itemRow(it, d.CSRFToken, d.PriceHistory[it.ID])
}
</tbody>
</table>
@@ -60,7 +142,7 @@ templ itemsBody(d ItemsData) {
templ itemsEmpty() {
<div class="v-card p-8 flex flex-col md:flex-row items-center gap-6">
<div class="v-veola-portrait w-48 shrink-0">
<img src="/static/img/veola.webp" alt="Veola"/>
<img src="/static/img/veola.avif" alt="Veola"/>
</div>
<div>
<h2 class="text-xl font-semibold mb-2">Nothing on the watchlist.</h2>
@@ -70,10 +152,15 @@ templ itemsEmpty() {
</div>
}
templ itemRow(it models.Item, csrf string) {
templ itemRow(it models.Item, csrf string, history []models.PricePoint) {
<tr id={ fmt.Sprintf("item-row-%d", it.ID) }>
<td>
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)) }>{ it.Name }</a>
<div class="flex items-center gap-2">
if isDeal(it) {
<img class="v-deal-mascot" src="/static/img/veola.avif" alt="" title={ fmt.Sprintf("Deal! Best %s ≤ target %s", fmtPrice(it.BestPrice, it.BestPriceCurrency), fmtPrice(it.TargetPrice, "USD")) }/>
}
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)) }>{ it.Name }</a>
</div>
if it.LastPollError != "" {
<button class="v-pill v-pill-error ml-2" hx-get={ fmt.Sprintf("/items/%d/error", it.ID) } hx-target={ fmt.Sprintf("#item-error-%d", it.ID) } hx-swap="innerHTML">!</button>
<div id={ fmt.Sprintf("item-error-%d", it.ID) } class="v-error-text mt-1"></div>
@@ -89,7 +176,12 @@ templ itemRow(it models.Item, csrf string) {
</td>
<td>
if it.BestPrice != nil {
<div class={ "font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice) }>{ fmtPrice(it.BestPrice, it.BestPriceCurrency) }</div>
<div class="flex items-center gap-1.5">
<span class={ "font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice) }>{ fmtPrice(it.BestPrice, it.BestPriceCurrency) }</span>
if glyph, cls := trendArrow(history); glyph != "" {
<span class={ "v-trend", cls }>{ glyph }</span>
}
</div>
if it.BestPriceURL != "" {
<a class="text-xs" href={ templ.SafeURL(it.BestPriceURL) } target="_blank" rel="noopener">{ it.BestPriceStore }</a>
} else if it.BestPriceStore != "" {
@@ -99,6 +191,15 @@ templ itemRow(it models.Item, csrf string) {
<span class="v-muted">not yet</span>
}
</td>
<td>
if pts := sparklinePoints(history); pts != "" {
<svg class={ "v-sparkline", sparklineTrendClass(history) } viewBox="0 0 80 24" width="80" height="24" aria-hidden="true">
<polyline points={ pts } fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
} else {
<span class="v-muted text-xs">—</span>
}
</td>
<td class="v-muted text-sm">
if it.LastPolledAt != nil {
<span title={ it.LastPolledAt.Format("2006-01-02 15:04:05") }>{ humanTime(*it.LastPolledAt) }</span>
@@ -154,9 +255,11 @@ templ Items(d ItemsData) {
@Layout(d.Page, itemsBody(d))
}
// ItemRow renders a single row partial, used by HTMX endpoints.
templ ItemRow(it models.Item, csrf string) {
@itemRow(it, csrf)
// ItemRow renders a single row partial, used by HTMX endpoints. Callers
// that don't have history cheaply on hand pass nil; the sparkline cell
// degrades to an em-dash placeholder.
templ ItemRow(it models.Item, csrf string, history []models.PricePoint) {
@itemRow(it, csrf, history)
}
// EmptyRow lets a delete handler return a row replacement that vanishes.