From ed03494edfc7306c1b405eaea8363331380eecfb Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:57:54 -0700 Subject: [PATCH] Window dashboard Money Saved to 30-day average --- internal/db/dedup_test.go | 46 ++++++++++++++++++++++++++++++++++++ internal/db/queries.go | 5 ++++ internal/scheduler/email.go | 4 ++-- templates/dashboard.templ | 2 +- templates/dashboard_templ.go | 2 +- 5 files changed, 55 insertions(+), 4 deletions(-) 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(`
%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(`