diff --git a/internal/db/dedup_test.go b/internal/db/dedup_test.go index 885986d..6a1385c 100644 --- a/internal/db/dedup_test.go +++ b/internal/db/dedup_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "testing" + "time" "veola/internal/crypto" "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) { s := newTestStore(t) ctx := context.Background() diff --git a/internal/db/queries.go b/internal/db/queries.go index d84f06f..303db83 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1040,11 +1040,16 @@ func (s *Store) GetDashboardStats(ctx context.Context) (*DashboardStats, error) `).Scan(&d.UnpricedCount); err != nil { 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, ` SELECT i.best_price, AVG(p.price) AS avg_price FROM items i JOIN price_history p ON p.item_id = i.id WHERE i.active = 1 AND i.best_price IS NOT NULL + AND p.polled_at >= datetime('now', '-30 day') GROUP BY i.id `) if err != nil { diff --git a/internal/scheduler/email.go b/internal/scheduler/email.go index 6e38913..9c9cfc1 100644 --- a/internal/scheduler/email.go +++ b/internal/scheduler/email.go @@ -137,7 +137,7 @@ func digestHTML(items []models.Item, stats *db.DashboardStats) string { b.WriteString(`
`) b.WriteString(`

Veola weekly digest

`) if stats != nil { - fmt.Fprintf(&b, `

%d active items. Estimated money saved so far: $%.2f.

`, + fmt.Fprintf(&b, `

%d active items. Estimated money saved vs 30-day average: $%.2f.

`, stats.ActiveItems, stats.MoneySaved) } b.WriteString(``) @@ -158,7 +158,7 @@ func digestText(items []models.Item, stats *db.DashboardStats) string { var b strings.Builder b.WriteString("Veola weekly digest\n\n") 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 { best := "no price yet" diff --git a/templates/dashboard.templ b/templates/dashboard.templ index 3c63604..3aca507 100644 --- a/templates/dashboard.templ +++ b/templates/dashboard.templ @@ -93,7 +93,7 @@ templ DashboardBody(d DashboardData) {
Money Saved
{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }
-
across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items
+
across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items, vs 30-day average
diff --git a/templates/dashboard_templ.go b/templates/dashboard_templ.go index 3b25e75..a55d553 100644 --- a/templates/dashboard_templ.go +++ b/templates/dashboard_templ.go @@ -196,7 +196,7 @@ func DashboardBody(d DashboardData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " items

API Usage and Budget

Apify runs cost credits. Visible to everyone.
Apify (month)
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " items, vs 30-day average

API Usage and Budget

Apify runs cost credits. Visible to everyone.
Apify (month)
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }