package templates
import (
"fmt"
"time"
"veola/internal/db"
"veola/internal/models"
)
type DashboardData struct {
Page
Stats *db.DashboardStats
RecentResults []ResultRow
RecentAlerts []AlertRow
// Budget surface, shown to every signed-in user (Apify calls cost money).
ApifyToday int
ApifyMonth int
ApifyCostPerCall float64
MonthlyBudget float64
EbayToday int
EbayLimit int
}
// ApifyMonthCost is the estimated month-to-date Apify spend in USD.
func (d DashboardData) ApifyMonthCost() float64 {
return float64(d.ApifyMonth) * d.ApifyCostPerCall
}
// BudgetPercent is month-to-date spend as a percent of the monthly budget,
// clamped to [0,100]. Zero when no budget is set.
func (d DashboardData) BudgetPercent() int {
if d.MonthlyBudget <= 0 {
return 0
}
p := int(d.ApifyMonthCost() / d.MonthlyBudget * 100)
if p < 0 {
return 0
}
if p > 100 {
return 100
}
return p
}
// BudgetOver reports whether estimated spend has exceeded the monthly budget.
func (d DashboardData) BudgetOver() bool {
return d.MonthlyBudget > 0 && d.ApifyMonthCost() > d.MonthlyBudget
}
type ResultRow struct {
ItemID int64
ItemName string
Title string
Price *float64
Currency string
Source string
URL string
FoundAt time.Time
Alerted bool
}
type AlertRow struct {
ItemName string
Price *float64
Currency string
FoundAt time.Time
}
// DashboardBody is the self-refreshing inner block. It is both the initial
// render target (inside Layout) and the response to /dashboard/refresh, so the
// hx-swap="outerHTML" on its root div replaces it with a fresh copy of itself.
// GetDashboardRefresh must render THIS, not Dashboard — rendering the full
// Layout would inject a nested page (and a duplicate sidebar) into the div.
templ DashboardBody(d DashboardData) {
Dashboard
@statCard("Total Items", fmt.Sprintf("%d", d.Stats.TotalItems), "")
@statCard("Active", fmt.Sprintf("%d", d.Stats.ActiveItems), "")
@statCard("Results Today", fmt.Sprintf("%d", d.Stats.ResultsToday), "")
@statCard("Alerts Today", fmt.Sprintf("%d", d.Stats.AlertsToday), "")
Potential Spend
{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }
across { fmt.Sprintf("%d", d.Stats.PricedItemCount) } items
if d.Stats.UnpricedCount > 0 {
{ fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount) }
}
Money Saved
{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }
across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items
API Usage and Budget
Apify runs cost credits. Visible to everyone.
Apify (month)
{ fmt.Sprintf("%d", d.ApifyMonth) }
if d.ApifyCostPerCall > 0 {
{ fmt.Sprintf("~$%.2f est.", d.ApifyMonthCost()) }
}
Apify (today)
{ fmt.Sprintf("%d", d.ApifyToday) }
eBay (today)
if d.EbayLimit > 0 {
{ fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit) }
} else {
{ fmt.Sprintf("%d", d.EbayToday) }
}
if d.MonthlyBudget > 0 {
Monthly Budget
{ fmt.Sprintf("$%.0f", d.MonthlyBudget) }
}
if d.MonthlyBudget > 0 {
{ fmt.Sprintf("%d%% of budget used", d.BudgetPercent()) }
if d.BudgetOver() {
Over budget
}
}
Recent Results
if len(d.RecentResults) == 0 {
No results yet.
} else {
| Item | Price | Source | Found |
for _, r := range d.RecentResults {
| { r.ItemName } |
{ fmtPrice(r.Price, r.Currency) } |
{ r.Source } |
{ humanTime(r.FoundAt) } |
}
}
Recent Alerts
if len(d.RecentAlerts) == 0 {
No alerts sent yet.
} else {
for _, a := range d.RecentAlerts {
-
{ a.ItemName }
{ fmtPrice(a.Price, a.Currency) }
}
}
}
templ statCard(label, value, sub string) {
{ label }
{ value }
if sub != "" {
{ sub }
}
}
templ Dashboard(d DashboardData) {
@Layout(d.Page, DashboardBody(d))
}
// Helpers used by multiple templates.
func fmtPrice(p *float64, currency string) string {
if p == nil {
return "—"
}
sym := currencySymbol(currency)
return fmt.Sprintf("%s%.2f", sym, *p)
}
func currencySymbol(c string) string {
switch c {
case "USD", "":
return "$"
case "GBP":
return "£"
case "EUR":
return "€"
case "JPY":
return "¥"
default:
return c + " "
}
}
func humanTime(t time.Time) string {
if t.IsZero() {
return "—"
}
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
m := int(d.Minutes())
return fmt.Sprintf("%d minutes ago", m)
case d < 24*time.Hour:
h := int(d.Hours())
return fmt.Sprintf("%d hours ago", h)
case d < 30*24*time.Hour:
d2 := int(d.Hours() / 24)
return fmt.Sprintf("%d days ago", d2)
default:
return t.Format("2006-01-02")
}
}
// Used by item rendering.
var _ = models.Item{}