Files
veola/templates/dashboard.templ
prosolis 7c95e4fd4e Add Authentik SSO, budget visibility, Resend email, 12h item default
Opens Veola up to more users (Parodia's Authentik) and makes Apify spend
visible to everyone.

- Auth: Traefik forward-auth (trust X-Authentik-* headers only from
  trusted_proxies CIDRs, keyed by email, role synced from admin_group),
  keeping local password login as break-glass. New [auth] config,
  CaptureDirectIP + ForwardAuth middleware, deploy/authentik-forward-auth.md.
- Budget: count every Apify run (apify_api_usage table) and show
  calls + estimated cost to all users on the dashboard, with an optional
  monthly-budget bar. New [budget] config + settings.
- Email: Resend client for opt-in deal alerts and a weekly digest
  (Mon 09:00). Per-user email + toggles on Settings. New [resend] config.
- Defaults: new items default to a 12-hour poll interval to cut spend.

users table gains email/auth_source/email-pref columns (migrated in place).
go build/vet/test green; boots and migrates cleanly.
2026-06-20 11:12:11 -07:00

248 lines
7.4 KiB
Plaintext

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) {
<div hx-get="/dashboard/refresh" hx-trigger="every 60s" hx-swap="outerHTML">
<h1 class="text-3xl font-semibold mb-6">Dashboard</h1>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
@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), "")
</div>
<div class="grid md:grid-cols-2 gap-4 mb-6">
<div class="v-card p-5">
<div class="v-muted text-sm uppercase tracking-wide">Potential Spend</div>
<div class="font-mono text-4xl mt-2" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }</div>
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.PricedItemCount) } items</div>
if d.Stats.UnpricedCount > 0 {
<div class="v-muted text-xs mt-1">{ fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount) }</div>
}
</div>
<div class="v-card p-5">
<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="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } 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">{ fmt.Sprintf("%d", d.ApifyMonth) }</div>
if d.ApifyCostPerCall > 0 {
<div class="v-muted text-xs mt-1">{ fmt.Sprintf("~$%.2f est.", d.ApifyMonthCost()) }</div>
}
</div>
<div>
<div class="v-muted text-xs uppercase tracking-wide">Apify (today)</div>
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d", d.ApifyToday) }</div>
</div>
<div>
<div class="v-muted text-xs uppercase tracking-wide">eBay (today)</div>
if d.EbayLimit > 0 {
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit) }</div>
} else {
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d", d.EbayToday) }</div>
}
</div>
if d.MonthlyBudget > 0 {
<div>
<div class="v-muted text-xs uppercase tracking-wide">Monthly Budget</div>
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("$%.0f", d.MonthlyBudget) }</div>
</div>
}
</div>
if d.MonthlyBudget > 0 {
<div class="mt-4">
<progress class="v-progress w-full" value={ fmt.Sprintf("%d", d.BudgetPercent()) } max="100"></progress>
<div class="flex justify-between text-xs mt-1">
<span class="v-muted">{ fmt.Sprintf("%d%% of budget used", d.BudgetPercent()) }</span>
if d.BudgetOver() {
<span class="v-price-target">Over budget</span>
}
</div>
</div>
}
</div>
<div class="grid md:grid-cols-2 gap-6">
<div class="v-card p-5">
<h2 class="font-semibold mb-3">Recent Results</h2>
if len(d.RecentResults) == 0 {
<div class="v-muted text-sm">No results yet.</div>
} else {
<table class="v-table">
<thead>
<tr><th>Item</th><th>Price</th><th>Source</th><th>Found</th></tr>
</thead>
<tbody>
for _, r := range d.RecentResults {
<tr>
<td><a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)) }>{ r.ItemName }</a></td>
<td class="font-mono">{ fmtPrice(r.Price, r.Currency) }</td>
<td>{ r.Source }</td>
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
</tr>
}
</tbody>
</table>
}
</div>
<div class="v-card p-5">
<h2 class="font-semibold mb-3">Recent Alerts</h2>
if len(d.RecentAlerts) == 0 {
<div class="v-muted text-sm">No alerts sent yet.</div>
} else {
<ul class="space-y-2">
for _, a := range d.RecentAlerts {
<li class="flex justify-between items-center border-b border-white/10 pb-2">
<span>{ a.ItemName }</span>
<span class="font-mono v-price-target">{ fmtPrice(a.Price, a.Currency) }</span>
</li>
}
</ul>
}
</div>
</div>
</div>
}
templ statCard(label, value, sub string) {
<div class="v-card p-4">
<div class="v-muted text-xs uppercase tracking-wide">{ label }</div>
<div class="font-mono text-3xl mt-1" data-countup>{ value }</div>
if sub != "" {
<div class="v-muted text-xs mt-1">{ sub }</div>
}
</div>
}
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{}