Auction handling: - Capture itemEndDate from eBay Browse API and ending_date from ZenMarket (Yahoo JP); plumb through results.ends_at column. Permissive ZenMarket parser (multiple layouts, JST when offset missing). - Per-row "Ends" countdown column + "Ending soon" banner on results pages, live-ticked by flair.js with urgent/critical tinting under 1h/5m. - Backfill ends_at for known auctions when their URL reappears in a poll (dedup hit no longer drops the new end time). - Hide ended auctions from result listings by default via ResultsQuery.ExcludeEnded; rows stay in the DB. Visual flair: - Glassy backdrop-blur v-cards with gradient-mask borders and hover-lift. - htmx swap fade-in via transient .v-just-swapped class. - Count-up animation on dashboard stats. All animations gated behind prefers-reduced-motion. eBay condition + region filters (auctions-style scoping): - items.condition and items.region columns; threaded through item form, CreateItem/UpdateItem, scheduler eBay plan input, and previewKey so cache invalidates when these change. - ebay.SearchParams gains conditionIds and itemLocationCountry filters. Run Now reload + countdown engine: - Run Now now sets HX-Refresh: true (non-htmx fallback: 303 redirect) so the entire results view — best price, chart, badge, last polled — reflects the new poll, instead of swapping just one partial. Pre-launch hardening (P1 set): - auth.EqualizeLoginTiming on no-such-user branch. - (*App).serverError centralizes 500s; replaces err.Error() leaks across results/settings/items/users/dashboard handlers. - main.go server: ReadTimeout 30s / WriteTimeout 60s / IdleTimeout 120s alongside the existing ReadHeaderTimeout. - noListFS wrapper blocks static directory listings. - Credential fields in settings no longer render value=; blank submission preserves the saved value, with per-field "Saved in settings / Set in config.toml / Not set" status indicator. Misc: - -debug flag wires slog to LevelDebug; raw ZenMarket items logged for format diagnosis. - /healthz public endpoint for reverse-proxy probes. - deploy/veola.service systemd unit template (hardening flags, single ReadWritePaths=/var/lib/veola). - handlers_test.go covers /healthz, setup-gate redirect, auth gate, and /login render with httptest + in-memory sqlite. - best_price_currency on items; templates pick the right symbol per row. - .gitignore now excludes *.log / veola-debug.log. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
4.8 KiB
Plaintext
171 lines
4.8 KiB
Plaintext
package templates
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"veola/internal/db"
|
|
"veola/internal/models"
|
|
)
|
|
|
|
type DashboardData struct {
|
|
Page
|
|
Stats *db.DashboardStats
|
|
RecentResults []ResultRow
|
|
RecentAlerts []AlertRow
|
|
}
|
|
|
|
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="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{}
|