diff --git a/Makefile b/Makefile index 9a1bf32..c00b87f 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,28 @@ # Veola build. # -# Requires the `templ` CLI (go install github.com/a-h/templ/cmd/templ@latest). -# The Tailwind standalone CLI is fetched on demand into bin/ (gitignored) — no -# node toolchain required. static/css/tailwind.css is a committed build -# artifact so a plain `go build` deploy still has styles; run `make css` -# (or `make build`) after touching templates or static/css/input.css. +# Tool dependencies — the `*_VERSION` variables below are the single source of +# truth for every non-Go thing Veola pulls in at build time. To upgrade one, +# bump its version and run `make vendor` (vendored JS) or just `make css` +# (Tailwind) on a clean `bin/` to refetch. +# +# Go module dependencies live in go.mod and are bumped via `make update-deps`. TAILWIND_VERSION := v3.4.17 -TAILWIND_BIN := bin/tailwindcss -# linux-x64 only; change the asset name for other platforms. -TAILWIND_URL := https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64 -TEMPL := $(shell go env GOPATH)/bin/templ +HTMX_VERSION := 2.0.4 +CHARTJS_VERSION := 4.4.6 +TEMPL_VERSION := v0.3.1020 -.PHONY: all generate css build run test clean +TAILWIND_BIN := bin/tailwindcss +# Override TAILWIND_URL for non-linux-x64 platforms — see README "Build-time +# tools" for the matching asset names on the Tailwind releases page. +TAILWIND_URL ?= https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64 + +HTMX_URL := https://unpkg.com/htmx.org@$(HTMX_VERSION)/dist/htmx.min.js +CHARTJS_URL := https://cdn.jsdelivr.net/npm/chart.js@$(CHARTJS_VERSION)/dist/chart.umd.min.js + +TEMPL := $(shell go env GOPATH)/bin/templ + +.PHONY: all generate css build run test clean tools vendor update-deps all: build @@ -41,3 +51,32 @@ test: clean: rm -f veola-bin + +# Install the pinned templ CLI into $(go env GOPATH)/bin. Pinning matters +# because two contributors on different templ versions can produce +# differently-formatted generated Go for the same .templ source. +tools: + go install github.com/a-h/templ/cmd/templ@$(TEMPL_VERSION) + +# Re-fetch vendored third-party JS at the currently pinned versions, +# overwriting whatever is on disk. Also clears bin/tailwindcss so the next +# `make css` pulls the pinned Tailwind binary. Use after bumping any of +# HTMX_VERSION / CHARTJS_VERSION / TAILWIND_VERSION. +vendor: + curl -sL --fail $(HTMX_URL) -o static/vendor/htmx.min.js + curl -sL --fail $(CHARTJS_URL) -o static/vendor/chart.umd.min.js + rm -f $(TAILWIND_BIN) + $(MAKE) $(TAILWIND_BIN) + +# Bump Go module dependencies to their newest compatible versions, then +# print upstream-release URLs for the non-Go tools so you can decide +# whether to bump those pins too. Does not modify the pinned versions. +update-deps: + go get -u ./... + go mod tidy + @echo + @echo "Pinned tool versions (current → upstream releases):" + @printf " Tailwind %s → https://github.com/tailwindlabs/tailwindcss/releases/latest\n" "$(TAILWIND_VERSION)" + @printf " htmx %s → https://github.com/bigskysoftware/htmx/releases/latest\n" "$(HTMX_VERSION)" + @printf " Chart.js %s → https://github.com/chartjs/Chart.js/releases/latest\n" "$(CHARTJS_VERSION)" + @printf " templ %s → https://github.com/a-h/templ/releases/latest\n" "$(TEMPL_VERSION)" diff --git a/README.md b/README.md index d1ae0cd..921f7f2 100644 --- a/README.md +++ b/README.md @@ -18,18 +18,68 @@ See [`veola-spec.md`](veola-spec.md) for the full specification. ## Requirements - Go 1.22+ (developed against 1.25) +- `make`, `curl`, and a POSIX shell (for the Makefile) - A reachable [ntfy](https://ntfy.sh) instance (self-hosted or ntfy.sh) - An [eBay developer](https://developer.ebay.com) keyset (App ID + Cert ID) — for eBay marketplaces - An [Apify](https://apify.com) account + API key — for the non-eBay marketplaces -- To build from source: the [`templ`](https://templ.guide) CLI. The Tailwind standalone CLI is fetched automatically by the Makefile — no Node toolchain required. + +### Build-time tools + +All non-Go tool versions are pinned in the Makefile at the top: + +```make +TAILWIND_VERSION := v3.4.17 +HTMX_VERSION := 2.0.4 +CHARTJS_VERSION := 4.4.6 +TEMPL_VERSION := v0.3.1020 +``` + +Install `templ` yourself at the pinned version; Tailwind is downloaded for you on first `make css`. htmx and Chart.js are already vendored in `static/vendor/` at the pinned versions above and committed to the repo. + +```sh +# Install the pinned templ CLI. +make tools + +# (or, equivalently) +go install github.com/a-h/templ/cmd/templ@v0.3.1020 + +# Confirm it's on PATH (typically $(go env GOPATH)/bin). +templ --version +``` + +You **do not need Node, npm, or Yarn**. Tailwind ships as a self-contained binary — the Makefile fetches the [standalone Tailwind CLI](https://github.com/tailwindlabs/tailwindcss/releases) into `./bin/tailwindcss` on first `make css`. The `bin/` directory is gitignored. + +By default the Makefile downloads the **linux-x64** build. For other platforms, override `TAILWIND_URL` on the command line — pick the matching asset from the [Tailwind releases page](https://github.com/tailwindlabs/tailwindcss/releases/tag/v3.4.17): + +```sh +# macOS Apple Silicon +make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-macos-arm64 + +# macOS Intel +make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-macos-x64 + +# linux-arm64 +make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-linux-arm64 +``` + +Or drop a binary at `./bin/tailwindcss` yourself and the Makefile will use it. ## Build +First time: + +```sh +go install github.com/a-h/templ/cmd/templ@latest # if not already +make build # fetches Tailwind, runs templ, compiles +``` + +Subsequent builds: + ```sh make build ``` -This runs `templ generate`, compiles Tailwind, and produces `veola-bin`. Makefile targets: +This runs `templ generate`, compiles Tailwind utilities into `static/css/tailwind.css`, and produces `veola-bin`. Makefile targets: | Target | What it does | | --- | --- | @@ -38,10 +88,11 @@ This runs `templ generate`, compiles Tailwind, and produces `veola-bin`. Makefil | `make build` | `generate` + `css` + `go build -o veola-bin .` | | `make run` | `build`, then run against `config.toml` | | `make test` | `go test ./...` | +| `make clean` | Remove `veola-bin` | The binary is named `veola-bin` rather than `veola` because the module is also `veola` — `go build` cannot write a binary with the same name as the module dir. -`static/css/tailwind.css` is committed, so a deploy box can `go build -o veola-bin .` without the Tailwind CLI. Re-run `make css` whenever you change templates or `static/css/input.css`. The hand-written component layer in `static/css/app.css` is loaded separately and needs no rebuild. +`static/css/tailwind.css` is committed, so a deploy box can `go build -o veola-bin .` without the Tailwind CLI or even `make`. Re-run `make css` whenever you change templates or `static/css/input.css`. The hand-written component layer in `static/css/app.css` is loaded separately and needs no rebuild. ## Configure @@ -70,13 +121,22 @@ Other notable config: ./veola-bin -config config.toml ``` +CLI flags: + +| Flag | Default | Notes | +| --- | --- | --- | +| `-config ` | `config.toml` | Path to the TOML config file | +| `-debug` | off | Verbose `log/slog` at `LevelDebug`. Logs raw external API payloads (eBay / ZenMarket / etc.) — useful when diagnosing parse failures. Not for production. | + First-run flow: 1. Visit `http://localhost:8080/`. With no users, you are redirected to `/setup`. -2. Create the admin account. +2. Create the admin account. The first user is always an admin. 3. Log in at `/login`. 4. Add items at `/items/new`. Optionally fill in your eBay/Apify credentials and ntfy URL via `/settings` if you didn't put them in `config.toml`. The Settings page also shows the running eBay API call count for the day. +Account registration is admin-only — there is no public signup. Once at least one user exists, `/setup` returns 404. New users are created from the Settings page by an admin. + The scheduler starts with the server and polls each active item on its configured interval. The bottom-of-hour global poll runs every `scheduler.global_poll_interval_minutes`. ## Layout @@ -108,6 +168,30 @@ go test ./... Unit tests cover crypto round-trip, db round-trip and dedup, scheduler alert/badge logic, and eBay marketplace/filter mapping. No handler-level tests yet. +## Keeping dependencies current + +Veola pulls from four sources, all version-pinned for reproducibility. + +```sh +# Bump Go module deps to their newest compatible versions, then prints +# upstream-release URLs for the four pinned tools so you can spot bumps. +make update-deps + +# After bumping any of TAILWIND_VERSION / HTMX_VERSION / CHARTJS_VERSION +# in the Makefile, refetch the vendored assets at the new pins: +make vendor + +# After bumping TEMPL_VERSION: +make tools + +# Then rebuild and run the test suite: +make build && make test +``` + +The pinned tool versions live at the top of the Makefile. The vendored JS at `static/vendor/htmx.min.js` and `static/vendor/chart.umd.min.js` is committed and updated only by `make vendor`. Tailwind v3.4.17 is intentionally pinned — v4 is a breaking release that drops the `tailwind.config.js` format Veola uses. + +For automated tracking, point [Dependabot](https://docs.github.com/en/code-security/dependabot) or [Renovate](https://docs.renovatebot.com/) at the repo. Both can watch `go.mod` natively; Renovate's custom-regex managers can also track the `*_VERSION` lines in the Makefile. + ## Operate - The SQLite file lives at `server.db_path` (default `./veola.db`). Back this up — it holds your watched items, history, encrypted secrets, and user accounts. @@ -122,6 +206,25 @@ Veola speaks plain HTTP and is meant to sit behind a TLS-terminating reverse pro - Keep `server.secure_cookies = true` (the default). - Terminate TLS at the proxy and set HSTS there — Veola does not emit HSTS itself. - Veola sets `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, and `Referrer-Policy` on every response, and trusts `X-Forwarded-For` for client IPs — configure the proxy to strip client-supplied `X-Forwarded-*` headers so they cannot be spoofed. +- `GET /healthz` returns 200 `ok` with no auth — wire it up to your proxy/uptime probe. + +A hardened systemd unit template lives at [`deploy/veola.service`](deploy/veola.service). It assumes: + +- Binary at `/usr/local/bin/veola-bin` +- Config at `/etc/veola/config.toml` +- A `veola` system user with `/var/lib/veola` as its working / data directory (the only writable path under `ReadWritePaths`) + +Install sketch: + +```sh +sudo useradd --system --home /var/lib/veola --shell /usr/sbin/nologin veola +sudo install -d -o veola -g veola -m 0750 /var/lib/veola /etc/veola +sudo install -m 0755 veola-bin /usr/local/bin/veola-bin +sudo install -m 0640 -o veola -g veola config.toml /etc/veola/config.toml +sudo install -m 0644 deploy/veola.service /etc/systemd/system/veola.service +sudo systemctl daemon-reload +sudo systemctl enable --now veola +``` ## Aesthetic diff --git a/Veola.webp b/Veola.webp deleted file mode 100644 index 4bae768..0000000 Binary files a/Veola.webp and /dev/null differ diff --git a/internal/db/queries.go b/internal/db/queries.go index 999902c..fc4949f 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -798,6 +798,49 @@ func (s *Store) InsertPricePoint(ctx context.Context, p *models.PricePoint) erro return err } +// LoadRecentPriceHistory bulk-loads the last `perItem` price points (oldest → +// newest) for each item id. Returns a map keyed by item id; items with no +// history are absent from the map. Used by the items list page to render a +// sparkline per row without N+1 queries. +func (s *Store) LoadRecentPriceHistory(ctx context.Context, ids []int64, perItem int) (map[int64][]models.PricePoint, error) { + out := make(map[int64][]models.PricePoint, len(ids)) + if len(ids) == 0 || perItem <= 0 { + return out, nil + } + placeholders := make([]string, len(ids)) + args := make([]any, 0, len(ids)+1) + for i, id := range ids { + placeholders[i] = "?" + args = append(args, id) + } + args = append(args, perItem) + q := ` + WITH ranked AS ( + SELECT item_id, price, store, polled_at, + ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY polled_at DESC) AS rn + FROM price_history + WHERE item_id IN (` + strings.Join(placeholders, ",") + `) + ) + SELECT item_id, price, store, polled_at FROM ranked WHERE rn <= ? + ORDER BY item_id, polled_at ASC + ` + rows, err := s.DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var p models.PricePoint + var store sql.NullString + if err := rows.Scan(&p.ItemID, &p.Price, &store, &p.PolledAt); err != nil { + return nil, err + } + p.Store = s.dec(store.String) + out[p.ItemID] = append(out[p.ItemID], p) + } + return out, rows.Err() +} + func (s *Store) ListPriceHistory(ctx context.Context, itemID int64) ([]models.PricePoint, error) { rows, err := s.DB.QueryContext(ctx, `SELECT id, item_id, price, store, polled_at FROM price_history WHERE item_id = ? ORDER BY polled_at ASC`, diff --git a/internal/handlers/items.go b/internal/handlers/items.go index dcfdbde..79ef021 100644 --- a/internal/handlers/items.go +++ b/internal/handlers/items.go @@ -30,11 +30,22 @@ func (a *App) GetItems(w http.ResponseWriter, r *http.Request) { } } cats, _ := a.Store.ListCategories(r.Context()) + + // Bulk-load recent price history so each row can render a sparkline + // without N+1 queries. 20 points is enough for a meaningful trend line + // at 80px wide and stays cheap on the largest realistic watchlists. + ids := make([]int64, 0, len(items)) + for _, it := range items { + ids = append(ids, it.ID) + } + history, _ := a.Store.LoadRecentPriceHistory(r.Context(), ids, 20) + render(w, r, templates.Items(templates.ItemsData{ Page: a.page(r, "Items", "items"), Items: items, Categories: cats, SelectedCategory: cat, + PriceHistory: history, })) } @@ -387,7 +398,8 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) { return } a.Scheduler.SyncItem(*it) - render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context()))) + hist, _ := a.Store.LoadRecentPriceHistory(r.Context(), []int64{id}, 20) + render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context()), hist[id])) } func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) { diff --git a/static/css/app.css b/static/css/app.css index 235c548..f001fcf 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -176,6 +176,19 @@ a:hover { text-decoration: underline; } } .v-side-nav a:hover { color: white; } +/* The brand wordmark at the top of the sidebar is also an anchor (→ /), but + shouldn't pick up the active-item border-left / padding treatment that + the nav links get. Higher specificity overrides .v-side-nav a defaults. */ +.v-side-nav a.v-side-brand { + border-left: 0; + color: var(--text); + text-decoration: none; +} +.v-side-nav a.v-side-brand:hover { + text-decoration: none; + filter: brightness(1.15); +} + .v-veola-portrait { background: #f3ead8; border-radius: 12px; @@ -304,6 +317,156 @@ table.v-table tbody tr:hover td { background: rgba(0, 164, 228, 0.08); } 50% { opacity: 0.55; } } +/* Sparkline cells in the items list. Color follows trend: green when the + latest price is meaningfully below the running average (good news for a + watchlist), red when it's risen, neutral otherwise. */ +.v-sparkline { display: block; overflow: visible; } +.v-spark-down { color: var(--success); filter: drop-shadow(0 0 4px rgba(0, 228, 164, 0.45)); } +.v-spark-up { color: var(--danger); filter: drop-shadow(0 0 4px rgba(232, 64, 64, 0.40)); } +.v-spark-flat { color: var(--text-2); } + +/* Trend arrow rendered next to Best Price. Same palette as the sparkline, + so a glance at the column reads consistently. */ +.v-trend { font-size: 0.95rem; font-weight: 700; } +.v-trend-down { color: var(--success); } +.v-trend-up { color: var(--danger); } +.v-trend-flat { color: var(--text-2); } + +/* Mascot "deal" moment: Veola appears next to an item's name only when the + current best price is at or below target. Small, animated, decorative — + purely a delight hit on top of the existing "Deal" badge. */ +.v-deal-mascot { + width: 24px; + height: 24px; + border-radius: 6px; + object-fit: cover; + background: #f3ead8; + padding: 1px; + box-shadow: 0 0 0 1px rgba(0, 228, 164, 0.6), 0 0 12px rgba(0, 228, 164, 0.45); + animation: v-deal-bob 2.4s ease-in-out infinite; +} +@keyframes v-deal-bob { + 0%, 100% { transform: translateY(0) rotate(-2deg); } + 50% { transform: translateY(-2px) rotate(2deg); } +} + +@media (prefers-reduced-motion: reduce) { + .v-deal-mascot { animation: none; } +} + +/* --- Login / Setup chrome ----------------------------------------------- + The auth pages use the Bare layout (no sidebar) so the form has to carry + its own visual weight. The v-auth-* classes give it that: a glassy card + for the form, a softly-rotating conic halo behind the mascot, a + typeset wordmark + tagline, and the right column is left translucent so + the global aurora reaches edge-to-edge instead of stopping at a flat + blue block. */ + +.v-auth-wordmark { + font-family: 'Outfit', system-ui, sans-serif; + text-shadow: 0 2px 14px rgba(0, 164, 228, 0.30); +} + +.v-auth-card { + /* Slightly heavier shadow + inner glow ring since the card stands + alone on the page with no sidebar context. */ + box-shadow: + 0 16px 44px rgba(0, 0, 80, 0.55), + 0 0 0 1px rgba(0, 164, 228, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.v-auth-tagline { + margin-top: 1.5rem; + text-align: center; + letter-spacing: 0.34em; + font-size: 0.72rem; + text-transform: uppercase; + color: var(--text-2); + text-shadow: 0 0 12px rgba(0, 164, 228, 0.35); +} + +.v-auth-portrait-col { + /* Translucent overlay instead of a solid block: a soft inward vignette + plus a faint diagonal sheen, keeping the column distinct from the + form side without hiding the aurora behind it. */ + background: + radial-gradient(ellipse at center, rgba(0, 0, 0, 0.0) 30%, rgba(0, 0, 0, 0.25) 100%), + linear-gradient(135deg, rgba(0, 164, 228, 0.05) 0%, rgba(245, 196, 0, 0.04) 100%); + position: relative; + overflow: hidden; +} + +.v-auth-portrait-halo { + position: relative; + isolation: isolate; + width: min(420px, 80%); + display: flex; + align-items: center; + justify-content: center; + animation: v-auth-portrait-float 7s ease-in-out infinite; + will-change: transform; +} + +/* Rotating conic-gradient halo. Sits behind the portrait via z-index -1 + inside the isolated stacking context, blurred so it reads as a glow + rather than a hard rainbow. */ +.v-auth-portrait-halo::before { + content: ""; + position: absolute; + inset: -15%; + border-radius: 50%; + background: conic-gradient( + from 180deg, + rgba(0, 164, 228, 0.55), + rgba(245, 196, 0, 0.40), + rgba(232, 64, 64, 0.30), + rgba(0, 228, 164, 0.40), + rgba(0, 164, 228, 0.55) + ); + filter: blur(50px); + z-index: -1; + animation: v-auth-portrait-spin 22s linear infinite; + will-change: transform; +} + +/* A second, slower halo gives the impression of depth: two layers of + light rotating at different speeds keep the eye from locking onto a + single repeating pattern. */ +.v-auth-portrait-halo::after { + content: ""; + position: absolute; + inset: -25%; + border-radius: 50%; + background: conic-gradient( + from 0deg, + rgba(0, 228, 164, 0.18), + rgba(0, 164, 228, 0.10), + rgba(245, 196, 0, 0.14), + rgba(0, 228, 164, 0.18) + ); + filter: blur(70px); + z-index: -1; + animation: v-auth-portrait-spin 42s linear infinite reverse; + will-change: transform; + opacity: 0.85; +} + +@keyframes v-auth-portrait-spin { + to { transform: rotate(360deg); } +} + +@keyframes v-auth-portrait-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } +} + +@media (prefers-reduced-motion: reduce) { + .v-auth-portrait-halo, + .v-auth-portrait-halo::before, + .v-auth-portrait-halo::after { animation: none; } +} + @media (prefers-reduced-motion: reduce) { .v-card { transition: none; } .v-card:hover { transform: none; } diff --git a/static/css/retro.css b/static/css/retro.css new file mode 100644 index 0000000..1f9b344 --- /dev/null +++ b/static/css/retro.css @@ -0,0 +1,118 @@ +/* retro.css — optional "retro-arcade identity" layer. + * + * Entirely additive. To revert, delete this file and remove its tag + * from templates/layout.templ. Nothing else depends on it. + * + * What this adds: + * 1. Two independently-animated aurora blobs that drift across the + * viewport on different paths — the real "shifting light" feel, + * not a synchronized slide. + * 2. Dot-grid overlay over the whole viewport. + * 3. Heavier display headings with a soft accent glow. + * + * Both blobs live on html pseudo-elements at z-index -2 / -1, sitting + * behind everything else so card glassiness still reads on top. + */ + +body { + /* Let the html-level aurora show through. app.css set a solid blue here; + dropping it here unlocks the gradient. Removing retro.css restores the + original solid background automatically. */ + background: transparent; +} + +html { + background-color: var(--bg); + /* Static dot-grid baked into the root background so the two pseudo-element + slots stay free for the animated blobs. */ + background-image: radial-gradient( + circle, + rgba(255, 255, 255, 0.045) 1px, + transparent 1px + ); + background-size: 26px 26px; + background-attachment: fixed; +} + +/* Two aurora blobs animated with seven waypoints each (no `alternate`, so + the second half doesn't mirror the first) on incommensurate periods — + the two cycles drift in and out of phase, so the eye never locks onto a + repeating pattern even though each blob is technically on a loop. + Opacity also wobbles so the lights "breathe" instead of just sliding. */ + +html::before { + content: ""; + position: fixed; + inset: 0; + z-index: -2; + pointer-events: none; + background: radial-gradient( + closest-side, + rgba(0, 164, 228, 0.60), + rgba(0, 164, 228, 0.0) 70% + ); + width: 70vmax; + height: 70vmax; + border-radius: 50%; + filter: blur(60px); + /* Centered base; keyframes drive all motion from here. */ + top: 50%; left: 50%; + margin-top: -35vmax; margin-left: -35vmax; + animation: v-retro-drift-a 41s cubic-bezier(.6,.1,.4,.9) infinite; + will-change: transform, opacity; +} + +html::after { + content: ""; + position: fixed; + inset: 0; + z-index: -2; + pointer-events: none; + background: radial-gradient( + closest-side, + rgba(245, 196, 0, 0.50), + rgba(245, 196, 0, 0.0) 70% + ); + width: 55vmax; + height: 55vmax; + border-radius: 50%; + filter: blur(70px); + top: 50%; left: 50%; + margin-top: -27.5vmax; margin-left: -27.5vmax; + animation: v-retro-drift-b 29s cubic-bezier(.5,.2,.3,.8) infinite; + will-change: transform, opacity; +} + +@keyframes v-retro-drift-a { + 0% { transform: translate3d(-30vw, -20vh, 0) scale(1.00); opacity: 0.85; } + 14% { transform: translate3d( 5vw, -28vh, 0) scale(1.18); opacity: 1.00; } + 29% { transform: translate3d( 32vw, -10vh, 0) scale(0.92); opacity: 0.75; } + 43% { transform: translate3d( 18vw, 22vh, 0) scale(1.10); opacity: 0.95; } + 58% { transform: translate3d(-15vw, 30vh, 0) scale(1.05); opacity: 0.80; } + 72% { transform: translate3d(-38vw, 8vh, 0) scale(0.95); opacity: 1.00; } + 86% { transform: translate3d(-20vw, -12vh, 0) scale(1.12); opacity: 0.90; } + 100% { transform: translate3d(-30vw, -20vh, 0) scale(1.00); opacity: 0.85; } +} + +@keyframes v-retro-drift-b { + 0% { transform: translate3d( 28vw, -25vh, 0) scale(1.00); opacity: 0.70; } + 17% { transform: translate3d( 0vw, -10vh, 0) scale(1.20); opacity: 0.95; } + 33% { transform: translate3d(-30vw, 8vh, 0) scale(0.90); opacity: 0.80; } + 48% { transform: translate3d(-12vw, 28vh, 0) scale(1.10); opacity: 1.00; } + 64% { transform: translate3d( 22vw, 18vh, 0) scale(0.95); opacity: 0.75; } + 80% { transform: translate3d( 36vw, -2vh, 0) scale(1.15); opacity: 0.90; } + 100% { transform: translate3d( 28vw, -25vh, 0) scale(1.00); opacity: 0.70; } +} + +/* Display headings: heavier, slightly tighter, with an accent glow that ties + into the card border gradient. Class-free selectors so existing Tailwind + utilities (text-3xl etc.) stack on top untouched. */ +h1, h2 { + font-weight: 800; + letter-spacing: -0.02em; + text-shadow: 0 2px 14px rgba(0, 164, 228, 0.30); +} + +@media (prefers-reduced-motion: reduce) { + html::before, html::after { animation: none; } +} diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 336aa96..e45a086 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -1,2 +1,2 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}.my-4{margin-bottom:1rem;margin-top:1rem}.mb-0{margin-bottom:0}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-40{height:10rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-40{width:10rem}.w-48{width:12rem}.w-full{width:100%}.max-w-3xl{max-width:48rem}.max-w-6xl{max-width:72rem}.max-w-\[140px\]{max-width:140px}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.bg-\[\#152560\]{--tw-bg-opacity:1;background-color:rgb(21 37 96/var(--tw-bg-opacity,1))}.bg-black\/30{background-color:rgba(0,0,0,.3)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-4{padding-left:1rem;padding-right:1rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.pb-2{padding-bottom:.5rem}.pl-5{padding-left:1.25rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-wide{letter-spacing:.025em}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}@media (min-width:768px){.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[160px_1fr\]{grid-template-columns:160px 1fr}.md\:flex-row{flex-direction:row}} \ No newline at end of file +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}.my-4{margin-bottom:1rem;margin-top:1rem}.mb-0{margin-bottom:0}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-40{height:10rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-40{width:10rem}.w-48{width:12rem}.w-full{width:100%}.max-w-3xl{max-width:48rem}.max-w-6xl{max-width:72rem}.max-w-\[140px\]{max-width:140px}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.bg-\[\#152560\]{--tw-bg-opacity:1;background-color:rgb(21 37 96/var(--tw-bg-opacity,1))}.bg-black\/30{background-color:rgba(0,0,0,.3)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-4{padding-left:1rem;padding-right:1rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.pb-2{padding-bottom:.5rem}.pl-5{padding-left:1.25rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-wide{letter-spacing:.025em}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}@media (min-width:768px){.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[160px_1fr\]{grid-template-columns:160px 1fr}.md\:flex-row{flex-direction:row}} \ No newline at end of file diff --git a/static/img/veola.avif b/static/img/veola.avif new file mode 100644 index 0000000..64dec67 Binary files /dev/null and b/static/img/veola.avif differ diff --git a/static/img/veola.webp b/static/img/veola.webp deleted file mode 100644 index 4bae768..0000000 Binary files a/static/img/veola.webp and /dev/null differ diff --git a/static/js/price-chart.js b/static/js/price-chart.js index ecc8f6d..780dfac 100644 --- a/static/js/price-chart.js +++ b/static/js/price-chart.js @@ -9,7 +9,29 @@ return; } var data = JSON.parse(canvas.dataset.chart); - new Chart(canvas.getContext('2d'), { + var ctx = canvas.getContext('2d'); + + // Vertical gradient fill — bright accent at the top, transparent at the + // baseline — makes the chart read at a glance instead of as a thin line. + var fill = ctx.createLinearGradient(0, 0, 0, canvas.clientHeight || 200); + fill.addColorStop(0, 'rgba(0, 228, 164, 0.45)'); + fill.addColorStop(1, 'rgba(0, 228, 164, 0.00)'); + + // Chart.js plugin that paints a soft glow under the line stroke before + // the dataset draws. Cheap enough to keep on by default; respects + // prefers-reduced-motion only insofar as nothing animates here. + var glowPlugin = { + id: 'priceLineGlow', + beforeDatasetDraw: function (chart) { + var c = chart.ctx; + c.save(); + c.shadowColor = 'rgba(0, 228, 164, 0.55)'; + c.shadowBlur = 12; + }, + afterDatasetDraw: function (chart) { chart.ctx.restore(); } + }; + + new Chart(ctx, { type: 'line', data: { labels: data.labels, @@ -17,19 +39,37 @@ label: 'Best price', data: data.points, borderColor: '#00e4a4', - backgroundColor: 'rgba(0,228,164,0.15)', - pointBackgroundColor: '#e84040', + borderWidth: 2.5, + backgroundColor: fill, + pointBackgroundColor: '#ffffff', + pointBorderColor: '#00e4a4', + pointBorderWidth: 1.5, pointRadius: 3, - tension: 0.25, + pointHoverRadius: 5, + tension: 0.3, fill: true }] }, options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, scales: { - x: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.07)' } }, - y: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.07)' } } + x: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.06)' } }, + y: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.06)' } } }, - plugins: { legend: { labels: { color: '#ffffff' } } } - } + plugins: { + legend: { labels: { color: '#ffffff' } }, + tooltip: { + backgroundColor: 'rgba(20, 32, 80, 0.95)', + borderColor: 'rgba(0, 164, 228, 0.6)', + borderWidth: 1, + titleColor: '#ffffff', + bodyColor: '#a8c0f0', + padding: 10 + } + } + }, + plugins: [glowPlugin] }); })(); diff --git a/templates/items.templ b/templates/items.templ index a31a005..889a850 100644 --- a/templates/items.templ +++ b/templates/items.templ @@ -2,6 +2,8 @@ package templates import ( "fmt" + "math" + "strings" "veola/internal/models" ) @@ -11,6 +13,85 @@ type ItemsData struct { Items []models.Item Categories []string SelectedCategory string + // PriceHistory holds the last ~20 points per item id, ascending in time. + // Nil/missing entries render an empty sparkline cell. + PriceHistory map[int64][]models.PricePoint +} + +// sparklinePoints turns a price-history slice into an SVG polyline "points" +// attribute, normalized to a 80x24 viewBox with 2px padding so endpoints +// don't clip the stroke. Returns "" when there isn't enough history to draw. +func sparklinePoints(history []models.PricePoint) string { + if len(history) < 2 { + return "" + } + const w, h, pad = 80.0, 24.0, 2.0 + minP, maxP := math.Inf(1), math.Inf(-1) + for _, p := range history { + if p.Price < minP { + minP = p.Price + } + if p.Price > maxP { + maxP = p.Price + } + } + span := maxP - minP + if span == 0 { + // All-equal series: draw a flat line through the middle. + span = 1 + } + step := (w - 2*pad) / float64(len(history)-1) + parts := make([]string, len(history)) + for i, p := range history { + x := pad + float64(i)*step + y := h - pad - ((p.Price-minP)/span)*(h-2*pad) + parts[i] = fmt.Sprintf("%.1f,%.1f", x, y) + } + return strings.Join(parts, " ") +} + +// sparklineTrendClass compares the last point to the average of the prior +// points and returns a CSS class so the sparkline tints green (price down), +// red (up), or neutral. Threshold is ±3% to ignore tiny wobbles. +func sparklineTrendClass(history []models.PricePoint) string { + if len(history) < 2 { + return "" + } + last := history[len(history)-1].Price + var sum float64 + for _, p := range history[:len(history)-1] { + sum += p.Price + } + avg := sum / float64(len(history)-1) + if avg == 0 { + return "v-spark-flat" + } + switch { + case last <= avg*0.97: + return "v-spark-down" + case last >= avg*1.03: + return "v-spark-up" + } + return "v-spark-flat" +} + +// trendArrow returns the unicode arrow + a CSS class describing the direction. +// Same threshold logic as the sparkline. Empty when there's no trend signal. +func trendArrow(history []models.PricePoint) (glyph, class string) { + switch sparklineTrendClass(history) { + case "v-spark-down": + return "↓", "v-trend-down" + case "v-spark-up": + return "↑", "v-trend-up" + case "v-spark-flat": + return "→", "v-trend-flat" + } + return "", "" +} + +// isDeal is the gate for the mascot "deal" moment on a row. +func isDeal(it models.Item) bool { + return it.BestPrice != nil && it.TargetPrice != nil && *it.BestPrice <= *it.TargetPrice } templ itemsBody(d ItemsData) { @@ -41,6 +122,7 @@ templ itemsBody(d ItemsData) { Category Target Best Price + Trend Last Polled Status @@ -48,7 +130,7 @@ templ itemsBody(d ItemsData) { for _, it := range d.Items { - @itemRow(it, d.CSRFToken) + @itemRow(it, d.CSRFToken, d.PriceHistory[it.ID]) } @@ -60,7 +142,7 @@ templ itemsBody(d ItemsData) { templ itemsEmpty() {
- Veola + Veola

Nothing on the watchlist.

@@ -70,10 +152,15 @@ templ itemsEmpty() {
} -templ itemRow(it models.Item, csrf string) { +templ itemRow(it models.Item, csrf string, history []models.PricePoint) { - { it.Name } +
+ if isDeal(it) { + + } + { it.Name } +
if it.LastPollError != "" {
@@ -89,7 +176,12 @@ templ itemRow(it models.Item, csrf string) { if it.BestPrice != nil { -
{ fmtPrice(it.BestPrice, it.BestPriceCurrency) }
+
+ { fmtPrice(it.BestPrice, it.BestPriceCurrency) } + if glyph, cls := trendArrow(history); glyph != "" { + { glyph } + } +
if it.BestPriceURL != "" { { it.BestPriceStore } } else if it.BestPriceStore != "" { @@ -99,6 +191,15 @@ templ itemRow(it models.Item, csrf string) { not yet } + + if pts := sparklinePoints(history); pts != "" { + + } else { + + } + if it.LastPolledAt != nil { { humanTime(*it.LastPolledAt) } @@ -154,9 +255,11 @@ templ Items(d ItemsData) { @Layout(d.Page, itemsBody(d)) } -// ItemRow renders a single row partial, used by HTMX endpoints. -templ ItemRow(it models.Item, csrf string) { - @itemRow(it, csrf) +// ItemRow renders a single row partial, used by HTMX endpoints. Callers +// that don't have history cheaply on hand pass nil; the sparkline cell +// degrades to an em-dash placeholder. +templ ItemRow(it models.Item, csrf string, history []models.PricePoint) { + @itemRow(it, csrf, history) } // EmptyRow lets a delete handler return a row replacement that vanishes. diff --git a/templates/items_templ.go b/templates/items_templ.go index 46310cf..0d5c610 100644 --- a/templates/items_templ.go +++ b/templates/items_templ.go @@ -10,6 +10,8 @@ import templruntime "github.com/a-h/templ/runtime" import ( "fmt" + "math" + "strings" "veola/internal/models" ) @@ -19,6 +21,85 @@ type ItemsData struct { Items []models.Item Categories []string SelectedCategory string + // PriceHistory holds the last ~20 points per item id, ascending in time. + // Nil/missing entries render an empty sparkline cell. + PriceHistory map[int64][]models.PricePoint +} + +// sparklinePoints turns a price-history slice into an SVG polyline "points" +// attribute, normalized to a 80x24 viewBox with 2px padding so endpoints +// don't clip the stroke. Returns "" when there isn't enough history to draw. +func sparklinePoints(history []models.PricePoint) string { + if len(history) < 2 { + return "" + } + const w, h, pad = 80.0, 24.0, 2.0 + minP, maxP := math.Inf(1), math.Inf(-1) + for _, p := range history { + if p.Price < minP { + minP = p.Price + } + if p.Price > maxP { + maxP = p.Price + } + } + span := maxP - minP + if span == 0 { + // All-equal series: draw a flat line through the middle. + span = 1 + } + step := (w - 2*pad) / float64(len(history)-1) + parts := make([]string, len(history)) + for i, p := range history { + x := pad + float64(i)*step + y := h - pad - ((p.Price-minP)/span)*(h-2*pad) + parts[i] = fmt.Sprintf("%.1f,%.1f", x, y) + } + return strings.Join(parts, " ") +} + +// sparklineTrendClass compares the last point to the average of the prior +// points and returns a CSS class so the sparkline tints green (price down), +// red (up), or neutral. Threshold is ±3% to ignore tiny wobbles. +func sparklineTrendClass(history []models.PricePoint) string { + if len(history) < 2 { + return "" + } + last := history[len(history)-1].Price + var sum float64 + for _, p := range history[:len(history)-1] { + sum += p.Price + } + avg := sum / float64(len(history)-1) + if avg == 0 { + return "v-spark-flat" + } + switch { + case last <= avg*0.97: + return "v-spark-down" + case last >= avg*1.03: + return "v-spark-up" + } + return "v-spark-flat" +} + +// trendArrow returns the unicode arrow + a CSS class describing the direction. +// Same threshold logic as the sparkline. Empty when there's no trend signal. +func trendArrow(history []models.PricePoint) (glyph, class string) { + switch sparklineTrendClass(history) { + case "v-spark-down": + return "↓", "v-trend-down" + case "v-spark-up": + return "↑", "v-trend-up" + case "v-spark-flat": + return "→", "v-trend-flat" + } + return "", "" +} + +// isDeal is the gate for the mascot "deal" moment on a row. +func isDeal(it models.Item) bool { + return it.BestPrice != nil && it.TargetPrice != nil && *it.BestPrice <= *it.TargetPrice } func itemsBody(d ItemsData) templ.Component { @@ -59,7 +140,7 @@ func itemsBody(d ItemsData) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(c) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 28, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 109, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -82,7 +163,7 @@ func itemsBody(d ItemsData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(c) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 28, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 109, Col: 67} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -104,12 +185,12 @@ func itemsBody(d ItemsData) templ.Component { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
NameCategoryTargetBest PriceLast PolledStatus
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, it := range d.Items { - templ_7745c5c3_Err = itemRow(it, d.CSRFToken).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = itemRow(it, d.CSRFToken, d.PriceHistory[it.ID]).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -148,7 +229,7 @@ func itemsEmpty() templ.Component { templ_7745c5c3_Var4 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
\"Veola\"

Nothing on the watchlist.

Add an item and Veola will keep an eye on it.

Add the first item
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
\"Veola\"

Nothing on the watchlist.

Add an item and Veola will keep an eye on it.

Add the first item
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -156,7 +237,7 @@ func itemsEmpty() templ.Component { }) } -func itemRow(it models.Item, csrf string) templ.Component { +func itemRow(it models.Item, csrf string, history []models.PricePoint) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -184,418 +265,532 @@ func itemRow(it models.Item, csrf string) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("item-row-%d", it.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 74, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 156, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"> Edit") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -629,9 +824,9 @@ func Items(d ItemsData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var32 := templ.GetChildren(ctx) - if templ_7745c5c3_Var32 == nil { - templ_7745c5c3_Var32 = templ.NopComponent + templ_7745c5c3_Var39 := templ.GetChildren(ctx) + if templ_7745c5c3_Var39 == nil { + templ_7745c5c3_Var39 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = Layout(d.Page, itemsBody(d)).Render(ctx, templ_7745c5c3_Buffer) @@ -642,8 +837,10 @@ func Items(d ItemsData) templ.Component { }) } -// ItemRow renders a single row partial, used by HTMX endpoints. -func ItemRow(it models.Item, csrf string) templ.Component { +// ItemRow renders a single row partial, used by HTMX endpoints. Callers +// that don't have history cheaply on hand pass nil; the sparkline cell +// degrades to an em-dash placeholder. +func ItemRow(it models.Item, csrf string, history []models.PricePoint) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -659,12 +856,12 @@ func ItemRow(it models.Item, csrf string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var33 := templ.GetChildren(ctx) - if templ_7745c5c3_Var33 == nil { - templ_7745c5c3_Var33 = templ.NopComponent + templ_7745c5c3_Var40 := templ.GetChildren(ctx) + if templ_7745c5c3_Var40 == nil { + templ_7745c5c3_Var40 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = itemRow(it, csrf).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = itemRow(it, csrf, history).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -689,12 +886,12 @@ func EmptyRow() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var34 := templ.GetChildren(ctx) - if templ_7745c5c3_Var34 == nil { - templ_7745c5c3_Var34 = templ.NopComponent + templ_7745c5c3_Var41 := templ.GetChildren(ctx) + if templ_7745c5c3_Var41 == nil { + templ_7745c5c3_Var41 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/layout.templ b/templates/layout.templ index c5475d3..df554eb 100644 --- a/templates/layout.templ +++ b/templates/layout.templ @@ -23,6 +23,9 @@ templ head(title string) { + + @@ -30,10 +33,10 @@ templ head(title string) { templ Sidebar(active string) {
NameCategoryTargetBest PriceTrendLast PolledStatus
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 templ.SafeURL - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID))) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 76, Col: 67} + if isDeal(it) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"\" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + var templ_7745c5c3_Var8 templ.SafeURL + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID))) if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(it.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 76, Col: 79} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 162, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(it.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 162, Col: 80} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if it.LastPollError != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" hx-swap=\"innerHTML\">!
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(it.Category) + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(it.Category) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 82, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 169, Col: 35} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if it.TargetPrice != nil { - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.TargetPrice, "USD")) + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.TargetPrice, "USD")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 85, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 172, Col: 37} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if it.BestPrice != nil { - var templ_7745c5c3_Var14 = []any{"font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if it.BestPriceURL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if glyph, cls := trendArrow(history); glyph != "" { + var templ_7745c5c3_Var18 = []any{"v-trend", cls} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 templ.SafeURL - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(it.BestPriceURL)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 94, Col: 61} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" target=\"_blank\" rel=\"noopener\">") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 94, Col: 114} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if it.BestPriceStore != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(glyph) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 182, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if it.BestPriceURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 186, Col: 114} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if it.BestPriceStore != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 188, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "not yet") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "not yet") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if pts := sparklinePoints(history); pts != "" { + var templ_7745c5c3_Var24 = []any{"v-sparkline", sparklineTrendClass(history)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if it.LastPolledAt != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(*it.LastPolledAt)) + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(*it.LastPolledAt)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 104, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 205, Col: 95} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "—") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "—") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if it.Active { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "active") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "active") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "paused") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "paused") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
Edit