package templates import ( "fmt" "math" "strings" "veola/internal/models" ) type ItemsData struct { Page 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) {

Items

+ Add Item
if len(d.Categories) > 0 {
} if len(d.Items) == 0 { @itemsEmpty() } else {
for _, it := range d.Items { @itemRow(it, d.CSRFToken, d.PriceHistory[it.ID]) }
Name Category Target Best Price Trend Last Polled Status
}
} templ itemsEmpty() {
Veola

Nothing on the watchlist.

Add an item and Veola will keep an eye on it.

Add the first item
} templ itemRow(it models.Item, csrf string, history []models.PricePoint) {
if isDeal(it) { } { it.Name }
if it.LastPollError != "" {
} { it.Category } if it.TargetPrice != nil { { fmtPrice(it.TargetPrice, "USD") } } else { } if it.BestPrice != nil {
{ fmtPrice(it.BestPrice, it.BestPriceCurrency) } if glyph, cls := trendArrow(history); glyph != "" { { glyph } }
if it.BestPriceURL != "" { { it.BestPriceStore } } else if it.BestPriceStore != "" { { it.BestPriceStore } } } else { not yet } if pts := sparklinePoints(history); pts != "" { } else { } if it.LastPolledAt != nil { { humanTime(*it.LastPolledAt) } } else { — } if it.Active { active } else { paused }
Edit
} func priceClass(best, target *float64) string { if best == nil || target == nil { return "" } if *best <= *target { return "v-price-target" } return "" } templ Items(d ItemsData) { @Layout(d.Page, itemsBody(d)) } // 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. templ EmptyRow() { }