77 lines
1.7 KiB
Go
77 lines
1.7 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"veola/internal/models"
|
|
"veola/templates"
|
|
)
|
|
|
|
// PickBadge returns the highest-priority deal-quality badge that applies to
|
|
// an item, or an empty BadgeData if none match. Order:
|
|
// 1. All-time low
|
|
// 2. X% below 30-day avg (only when at least 10% below)
|
|
// 3. X% below target
|
|
func PickBadge(it models.Item, history []models.PricePoint, now time.Time) templates.BadgeData {
|
|
if it.BestPrice == nil {
|
|
return templates.BadgeData{}
|
|
}
|
|
best := *it.BestPrice
|
|
|
|
// 1. All-time low
|
|
if isAllTimeLow(best, history) {
|
|
return templates.BadgeData{Label: "All-time low", Class: "v-badge-low"}
|
|
}
|
|
|
|
// 2. X% below 30-day average
|
|
if avg, ok := windowedMean(history, now, 30*24*time.Hour); ok && best > 0 && avg > 0 {
|
|
pct := (avg - best) / avg * 100
|
|
if pct >= 10 {
|
|
return templates.BadgeData{
|
|
Label: fmt.Sprintf("%d%% below 30-day avg", int(pct+0.5)),
|
|
Class: "v-badge-avg",
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. X% below target
|
|
if it.TargetPrice != nil && *it.TargetPrice > 0 && best < *it.TargetPrice {
|
|
pct := (*it.TargetPrice - best) / *it.TargetPrice * 100
|
|
return templates.BadgeData{
|
|
Label: fmt.Sprintf("%d%% below target", int(pct+0.5)),
|
|
Class: "v-badge-target",
|
|
}
|
|
}
|
|
|
|
return templates.BadgeData{}
|
|
}
|
|
|
|
func isAllTimeLow(best float64, history []models.PricePoint) bool {
|
|
if len(history) == 0 {
|
|
return false
|
|
}
|
|
for _, p := range history {
|
|
if p.Price > 0 && p.Price < best {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func windowedMean(history []models.PricePoint, now time.Time, window time.Duration) (float64, bool) {
|
|
cutoff := now.Add(-window)
|
|
sum, n := 0.0, 0
|
|
for _, p := range history {
|
|
if p.PolledAt.Before(cutoff) {
|
|
continue
|
|
}
|
|
sum += p.Price
|
|
n++
|
|
}
|
|
if n == 0 {
|
|
return 0, false
|
|
}
|
|
return sum / float64(n), true
|
|
}
|