Window dashboard Money Saved to 30-day average
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"veola/internal/crypto"
|
"veola/internal/crypto"
|
||||||
"veola/internal/models"
|
"veola/internal/models"
|
||||||
@@ -74,6 +75,51 @@ func TestDedupByItemAndURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDashboardMoneySavedWindow(t *testing.T) {
|
||||||
|
if os.Getenv("CI_SKIP_SQLITE") != "" {
|
||||||
|
t.Skip()
|
||||||
|
}
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
bp := 100.0
|
||||||
|
id, err := s.CreateItem(ctx, &models.Item{
|
||||||
|
Name: "Windowed", NtfyTopic: "veola", Active: true,
|
||||||
|
PollIntervalMinutes: 60, NtfyPriority: "default",
|
||||||
|
BestPrice: &bp,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
// A stale point well outside the 30-day window at a much higher price. If
|
||||||
|
// the average were computed over all history this would inflate "saved".
|
||||||
|
if err := s.InsertPricePoint(ctx, &models.PricePoint{
|
||||||
|
ItemID: id, Price: 500, PolledAt: now.AddDate(0, 0, -60),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A recent point inside the window. Best price (100) is below it, so the
|
||||||
|
// only legitimate saving is 120 - 100 = 20.
|
||||||
|
if err := s.InsertPricePoint(ctx, &models.PricePoint{
|
||||||
|
ItemID: id, Price: 120, PolledAt: now.AddDate(0, 0, -5),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := s.GetDashboardStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stats.SavedItemCount != 1 {
|
||||||
|
t.Fatalf("expected 1 saved item, got %d", stats.SavedItemCount)
|
||||||
|
}
|
||||||
|
if got := stats.MoneySaved; got < 19.99 || got > 20.01 {
|
||||||
|
t.Errorf("expected money saved ~20 (recent avg only), got %.2f", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCJKRoundTrip(t *testing.T) {
|
func TestCJKRoundTrip(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -1040,11 +1040,16 @@ func (s *Store) GetDashboardStats(ctx context.Context) (*DashboardStats, error)
|
|||||||
`).Scan(&d.UnpricedCount); err != nil {
|
`).Scan(&d.UnpricedCount); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// "Money saved" compares each item's current best price against its recent
|
||||||
|
// market average. The average is windowed to the last 30 days so stale
|
||||||
|
// historical prices don't anchor the baseline; items whose only price
|
||||||
|
// history predates the window simply don't contribute an estimate.
|
||||||
rows, err := s.DB.QueryContext(ctx, `
|
rows, err := s.DB.QueryContext(ctx, `
|
||||||
SELECT i.best_price, AVG(p.price) AS avg_price
|
SELECT i.best_price, AVG(p.price) AS avg_price
|
||||||
FROM items i
|
FROM items i
|
||||||
JOIN price_history p ON p.item_id = i.id
|
JOIN price_history p ON p.item_id = i.id
|
||||||
WHERE i.active = 1 AND i.best_price IS NOT NULL
|
WHERE i.active = 1 AND i.best_price IS NOT NULL
|
||||||
|
AND p.polled_at >= datetime('now', '-30 day')
|
||||||
GROUP BY i.id
|
GROUP BY i.id
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func digestHTML(items []models.Item, stats *db.DashboardStats) string {
|
|||||||
b.WriteString(`<div style="font-family:sans-serif">`)
|
b.WriteString(`<div style="font-family:sans-serif">`)
|
||||||
b.WriteString(`<h2>Veola weekly digest</h2>`)
|
b.WriteString(`<h2>Veola weekly digest</h2>`)
|
||||||
if stats != nil {
|
if stats != nil {
|
||||||
fmt.Fprintf(&b, `<p>%d active items. Estimated money saved so far: $%.2f.</p>`,
|
fmt.Fprintf(&b, `<p>%d active items. Estimated money saved vs 30-day average: $%.2f.</p>`,
|
||||||
stats.ActiveItems, stats.MoneySaved)
|
stats.ActiveItems, stats.MoneySaved)
|
||||||
}
|
}
|
||||||
b.WriteString(`<table style="border-collapse:collapse" cellpadding="6">`)
|
b.WriteString(`<table style="border-collapse:collapse" cellpadding="6">`)
|
||||||
@@ -158,7 +158,7 @@ func digestText(items []models.Item, stats *db.DashboardStats) string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("Veola weekly digest\n\n")
|
b.WriteString("Veola weekly digest\n\n")
|
||||||
if stats != nil {
|
if stats != nil {
|
||||||
fmt.Fprintf(&b, "%d active items. Estimated money saved so far: $%.2f.\n\n", stats.ActiveItems, stats.MoneySaved)
|
fmt.Fprintf(&b, "%d active items. Estimated money saved vs 30-day average: $%.2f.\n\n", stats.ActiveItems, stats.MoneySaved)
|
||||||
}
|
}
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
best := "no price yet"
|
best := "no price yet"
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ templ DashboardBody(d DashboardData) {
|
|||||||
<div class="v-card p-5">
|
<div class="v-card p-5">
|
||||||
<div class="v-muted text-sm uppercase tracking-wide">Money Saved</div>
|
<div class="v-muted text-sm uppercase tracking-wide">Money Saved</div>
|
||||||
<div class="font-mono text-4xl mt-2 v-price-deal" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }</div>
|
<div class="font-mono text-4xl mt-2 v-price-deal" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }</div>
|
||||||
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items</div>
|
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items, vs 30-day average</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="v-card p-5 mb-6">
|
<div class="v-card p-5 mb-6">
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ func DashboardBody(d DashboardData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " items</div></div></div><div class=\"v-card p-5 mb-6\"><div class=\"flex items-center justify-between mb-3\"><h2 class=\"font-semibold\">API Usage and Budget</h2><span class=\"v-muted text-xs\">Apify runs cost credits. Visible to everyone.</span></div><div class=\"grid grid-cols-2 md:grid-cols-4 gap-4\"><div><div class=\"v-muted text-xs uppercase tracking-wide\">Apify (month)</div><div class=\"font-mono text-2xl mt-1\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " items, vs 30-day average</div></div></div><div class=\"v-card p-5 mb-6\"><div class=\"flex items-center justify-between mb-3\"><h2 class=\"font-semibold\">API Usage and Budget</h2><span class=\"v-muted text-xs\">Apify runs cost credits. Visible to everyone.</span></div><div class=\"grid grid-cols-2 md:grid-cols-4 gap-4\"><div><div class=\"v-muted text-xs uppercase tracking-wide\">Apify (month)</div><div class=\"font-mono text-2xl mt-1\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user