Auction end times, visual flair, and pre-launch cleanup
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>
This commit is contained in:
@@ -3,7 +3,9 @@ package templates
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"veola/internal/db"
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
@@ -21,6 +23,9 @@ type ItemResultsData struct {
|
||||
// normal page load; PostRunItem sets exactly one.
|
||||
RunMsg string
|
||||
RunError string
|
||||
// EndingSoon, when non-nil, surfaces the soonest-ending auction across this
|
||||
// item's results so users can act before close.
|
||||
EndingSoon *db.EndingSoon
|
||||
}
|
||||
|
||||
type BadgeData struct {
|
||||
@@ -35,6 +40,8 @@ type GlobalResultsData struct {
|
||||
ItemID int64
|
||||
From string
|
||||
To string
|
||||
// EndingSoon mirrors the per-item field but spans every watched item.
|
||||
EndingSoon *db.EndingSoon
|
||||
}
|
||||
|
||||
type ItemResultRow struct {
|
||||
@@ -42,8 +49,40 @@ type ItemResultRow struct {
|
||||
ItemName string
|
||||
}
|
||||
|
||||
// endingSoonStrip surfaces the next auction about to close. Hidden when nil
|
||||
// (the handler decides cutoff: 24h by default). The data-ends-at attribute
|
||||
// drives the live countdown in flair.js.
|
||||
templ endingSoonStrip(e *db.EndingSoon) {
|
||||
if e != nil {
|
||||
<div class="v-ending-strip mb-5" data-ends-at={ e.EndsAt.UTC().Format(time.RFC3339) }>
|
||||
<div class="v-ending-label">Ending soon</div>
|
||||
<div class="v-ending-title">
|
||||
if e.URL != "" {
|
||||
<a href={ templ.SafeURL(e.URL) } target="_blank" rel="noopener">{ e.Title }</a>
|
||||
} else {
|
||||
{ e.Title }
|
||||
}
|
||||
<span class="v-muted text-xs ml-2">{ e.ItemName }</span>
|
||||
</div>
|
||||
<div class="v-ending-countdown font-mono" data-countdown></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// endsInCell renders the per-row countdown for auction listings only. Fixed-
|
||||
// price rows (EndsAt == nil) get an em-dash placeholder so column widths stay
|
||||
// consistent.
|
||||
templ endsInCell(endsAt *time.Time) {
|
||||
if endsAt != nil {
|
||||
<span class="v-countdown font-mono text-sm" data-ends-at={ endsAt.UTC().Format(time.RFC3339) } data-countdown></span>
|
||||
} else {
|
||||
<span class="v-muted">—</span>
|
||||
}
|
||||
}
|
||||
|
||||
templ itemResultsBody(d ItemResultsData) {
|
||||
<div>
|
||||
@endingSoonStrip(d.EndingSoon)
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold">{ d.Item.Name }</h1>
|
||||
@@ -53,7 +92,7 @@ templ itemResultsBody(d ItemResultsData) {
|
||||
</div>
|
||||
<div class="text-right">
|
||||
if d.Item.BestPrice != nil {
|
||||
<div class="font-mono text-3xl">{ fmtPrice(d.Item.BestPrice, "USD") }</div>
|
||||
<div class="font-mono text-3xl">{ fmtPrice(d.Item.BestPrice, d.Item.BestPriceCurrency) }</div>
|
||||
if d.Item.BestPriceURL != "" {
|
||||
<a class="text-sm" href={ templ.SafeURL(d.Item.BestPriceURL) } target="_blank" rel="noopener">{ d.Item.BestPriceStore }</a>
|
||||
}
|
||||
@@ -120,6 +159,7 @@ templ ItemResultsTable(d ItemResultsData) {
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "price"))) }>Price</a>
|
||||
</th>
|
||||
<th>Store</th>
|
||||
<th>Ends</th>
|
||||
<th>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "found"))) }>Found</a>
|
||||
</th>
|
||||
@@ -146,6 +186,7 @@ templ ItemResultsTable(d ItemResultsData) {
|
||||
</td>
|
||||
<td class={ "font-mono", priceClass(r.Price, d.Item.TargetPrice) }>{ fmtPrice(r.Price, r.Currency) }</td>
|
||||
<td class="v-muted">{ r.Source }</td>
|
||||
<td>@endsInCell(r.EndsAt)</td>
|
||||
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
|
||||
<td>
|
||||
if r.Alerted {
|
||||
@@ -197,6 +238,7 @@ templ ItemResults(d ItemResultsData) {
|
||||
templ globalResultsBody(d GlobalResultsData) {
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold mb-6">All Results</h1>
|
||||
@endingSoonStrip(d.EndingSoon)
|
||||
<form method="get" action="/results" class="flex items-end gap-3 mb-4">
|
||||
<div>
|
||||
<label class="v-label">Item</label>
|
||||
@@ -220,7 +262,7 @@ templ globalResultsBody(d GlobalResultsData) {
|
||||
<div class="v-card p-0 overflow-hidden">
|
||||
<table class="v-table">
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Title</th><th>Price</th><th>Store</th><th>Found</th><th>Alert</th></tr>
|
||||
<tr><th>Item</th><th>Title</th><th>Price</th><th>Store</th><th>Ends</th><th>Found</th><th>Alert</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, r := range d.Results {
|
||||
@@ -238,6 +280,7 @@ templ globalResultsBody(d GlobalResultsData) {
|
||||
</td>
|
||||
<td class="font-mono">{ fmtPrice(r.Price, r.Currency) }</td>
|
||||
<td class="v-muted">{ r.Source }</td>
|
||||
<td>@endsInCell(r.EndsAt)</td>
|
||||
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
|
||||
<td>
|
||||
if r.Alerted {
|
||||
|
||||
Reference in New Issue
Block a user