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