From cfa01bd4ef16ab60d0ddcbc54de0f767a9a9faa7 Mon Sep 17 00:00:00 2001 From: drwily Date: Wed, 13 May 2026 19:42:49 -0700 Subject: [PATCH] Initial commit --- .gitignore | 18 + README.md | 102 ++++ Veola.webp | Bin 0 -> 57936 bytes config.toml.example | 40 ++ go.mod | 23 + go.sum | 36 ++ internal/apify/client.go | 152 +++++ internal/apify/types.go | 313 +++++++++++ internal/auth/auth.go | 214 +++++++ internal/config/config.go | 125 +++++ internal/crypto/crypto.go | 94 ++++ internal/crypto/crypto_test.go | 86 +++ internal/db/db.go | 65 +++ internal/db/dedup_test.go | 97 ++++ internal/db/queries.go | 747 +++++++++++++++++++++++++ internal/db/schema.sql | 99 ++++ internal/handlers/auth.go | 113 ++++ internal/handlers/dashboard.go | 106 ++++ internal/handlers/handlers.go | 146 +++++ internal/handlers/items.go | 453 +++++++++++++++ internal/handlers/preview_cache.go | 66 +++ internal/handlers/results.go | 145 +++++ internal/handlers/settings.go | 195 +++++++ internal/handlers/users.go | 101 ++++ internal/models/models.go | 122 ++++ internal/ntfy/client.go | 98 ++++ internal/scheduler/alert.go | 84 +++ internal/scheduler/alert_test.go | 107 ++++ internal/scheduler/badge.go | 76 +++ internal/scheduler/badge_test.go | 95 ++++ internal/scheduler/json.go | 7 + internal/scheduler/scheduler.go | 599 ++++++++++++++++++++ main.go | 105 ++++ static/css/app.css | 209 +++++++ static/img/veola.webp | Bin 0 -> 57936 bytes static/vendor/chart.umd.min.js | 20 + static/vendor/htmx.min.js | 1 + templates/dashboard.templ | 165 ++++++ templates/dashboard_templ.go | 467 ++++++++++++++++ templates/item_form.templ | 302 ++++++++++ templates/item_form_templ.go | 760 +++++++++++++++++++++++++ templates/item_preview.templ | 163 ++++++ templates/item_preview_templ.go | 636 +++++++++++++++++++++ templates/items.templ | 162 ++++++ templates/items_templ.go | 705 +++++++++++++++++++++++ templates/layout.templ | 86 +++ templates/layout_templ.go | 370 ++++++++++++ templates/login.templ | 95 ++++ templates/login_templ.go | 227 ++++++++ templates/results.templ | 266 +++++++++ templates/results_templ.go | 866 +++++++++++++++++++++++++++++ templates/settings.templ | 156 ++++++ templates/settings_templ.go | 447 +++++++++++++++ veola-spec.md | 786 ++++++++++++++++++++++++++ 54 files changed, 11718 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Veola.webp create mode 100644 config.toml.example create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/apify/client.go create mode 100644 internal/apify/types.go create mode 100644 internal/auth/auth.go create mode 100644 internal/config/config.go create mode 100644 internal/crypto/crypto.go create mode 100644 internal/crypto/crypto_test.go create mode 100644 internal/db/db.go create mode 100644 internal/db/dedup_test.go create mode 100644 internal/db/queries.go create mode 100644 internal/db/schema.sql create mode 100644 internal/handlers/auth.go create mode 100644 internal/handlers/dashboard.go create mode 100644 internal/handlers/handlers.go create mode 100644 internal/handlers/items.go create mode 100644 internal/handlers/preview_cache.go create mode 100644 internal/handlers/results.go create mode 100644 internal/handlers/settings.go create mode 100644 internal/handlers/users.go create mode 100644 internal/models/models.go create mode 100644 internal/ntfy/client.go create mode 100644 internal/scheduler/alert.go create mode 100644 internal/scheduler/alert_test.go create mode 100644 internal/scheduler/badge.go create mode 100644 internal/scheduler/badge_test.go create mode 100644 internal/scheduler/json.go create mode 100644 internal/scheduler/scheduler.go create mode 100644 main.go create mode 100644 static/css/app.css create mode 100644 static/img/veola.webp create mode 100644 static/vendor/chart.umd.min.js create mode 100644 static/vendor/htmx.min.js create mode 100644 templates/dashboard.templ create mode 100644 templates/dashboard_templ.go create mode 100644 templates/item_form.templ create mode 100644 templates/item_form_templ.go create mode 100644 templates/item_preview.templ create mode 100644 templates/item_preview_templ.go create mode 100644 templates/items.templ create mode 100644 templates/items_templ.go create mode 100644 templates/layout.templ create mode 100644 templates/layout_templ.go create mode 100644 templates/login.templ create mode 100644 templates/login_templ.go create mode 100644 templates/results.templ create mode 100644 templates/results_templ.go create mode 100644 templates/settings.templ create mode 100644 templates/settings_templ.go create mode 100644 veola-spec.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..638d1ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Binaries +veola-bin +*.exe + +# Local config (use config.toml.example as template) +config.toml + +# Database +*.db +*.db-journal +*.db-wal +*.db-shm + +# OS / editor +.DS_Store +*.swp +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec1f8a1 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Veola + +Self-hosted Go web app that tracks items across e-commerce platforms (eBay, Amazon family, Yahoo Auctions JP, Mercari JP) via the [Apify](https://apify.com) scraping API and pushes deal alerts to a self-hosted [ntfy](https://ntfy.sh) instance. + +Track. Watch. Notice. + +## Features + +- Watch arbitrary items across multiple marketplaces with per-item search queries, target prices, and poll intervals +- Active-listing, sold-listing, and price-comparison actors per item +- Price-history chart and best-price badge once enough history accumulates +- Deal alerts pushed to ntfy when current price falls at or below target +- Single-binary deploy, SQLite storage, no CGO + +See [`veola-spec.md`](veola-spec.md) for the full specification. + +## Requirements + +- Go 1.22+ (developed against 1.25) +- An [Apify](https://apify.com) account + API key +- A reachable [ntfy](https://ntfy.sh) instance (self-hosted or ntfy.sh) + +## Build + +```sh +go build -o 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. + +If you change any `.templ` files, regenerate first: + +```sh +~/go/bin/templ generate +``` + +## Configure + +Copy the example and edit: + +```sh +cp config.toml.example config.toml +``` + +Both `session_secret` and `encryption_key` must be at least 32 bytes and different from each other. Generate with: + +```sh +openssl rand -hex 32 +``` + +`encryption_key` encrypts secrets at rest in SQLite (Apify keys, ntfy settings). Rotating it invalidates stored secrets — re-enter them through `/settings` after rotation. + +## Run + +```sh +./veola-bin -config config.toml +``` + +First-run flow: + +1. Visit `http://localhost:8080/`. With no users, you are redirected to `/setup`. +2. Create the admin account. +3. Log in at `/login`. +4. Add items at `/items/new`. Optionally fill in your Apify key and ntfy URL via `/settings` if you didn't put them in `config.toml`. + +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 + +``` +main.go entry point: config, db open, scheduler, http server +internal/ + config/ TOML config loading and validation + crypto/ AES-GCM encryption for secrets at rest + db/ SQLite schema, migrations, store + models/ domain types + apify/ Apify API client + ntfy/ ntfy push client + auth/ session + CSRF + scheduler/ poll loop, alert/dedup/badge logic + handlers/ HTTP handlers +templates/ templ components +static/ CSS, vendored htmx +``` + +## Test + +```sh +go test ./... +``` + +Unit tests cover crypto round-trip, db round-trip and dedup, and scheduler alert/badge logic. No handler-level tests yet. + +## 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. +- The process responds to `SIGINT` / `SIGTERM` with a graceful HTTP shutdown (30s timeout) followed by scheduler stop. +- Logs go to stdout as structured `log/slog` records. + +## Aesthetic + +Sega Genesis blue. Not dark mode, not light mode — blue mode. See the visual design section of `veola-spec.md` for the palette. diff --git a/Veola.webp b/Veola.webp new file mode 100644 index 0000000000000000000000000000000000000000..4bae7683af2faf53b7b05bf48f8ced6f20de88c9 GIT binary patch literal 57936 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 YHrgtZU{T-hsVz8rFU_y-YhC~V0GAR$3;+NC literal 0 HcmV?d00001 diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..e877f5b --- /dev/null +++ b/config.toml.example @@ -0,0 +1,40 @@ +[server] +port = 8080 +db_path = "./veola.db" + +[security] +# Both must be at least 32 bytes and different from each other. +# Generate with: openssl rand -hex 32 +session_secret = "change-this-to-a-random-32-byte-string-aaaa" +encryption_key = "change-this-to-a-different-random-32-byte-string-bb" + +[apify] +api_key = "" + +# Apify proxy configuration. If use_apify_proxy is false (or this whole +# block is absent), Veola omits proxyConfiguration from actor input entirely +# and the actor handles its own routing. RESIDENTIAL costs more credits and +# requires a plan that includes it; AUTO (or empty groups) uses whatever +# your plan provides. +[apify.proxy] +use_apify_proxy = false +# groups = ["RESIDENTIAL"] # or [] for AUTO +# country = "US" # ISO-3166-1 alpha-2; match your eBay region + +# Actor IDs verified on apify.com/store at build time. Pricing varies; check +# each actor's listing before enabling. Empty values disable that actor. +[apify.actors] +active_listings = "automation-lab/ebay-scraper" +sold_listings = "automation-lab/ebay-sold-scraper" +price_comparison = "" # set to a verified slug if you want price-comparison overlays +yahoo_auctions_jp = "meron1122/zenmarket-scraper" +yahoo_auctions_jp_sold = "" # no known verified sold-listings actor for Yahoo JP +mercari_jp = "cloud9_ai/mercari-scraper" + +[ntfy] +base_url = "https://ntfy.yourdomain.com" +default_topic = "veola" + +[scheduler] +global_poll_interval_minutes = 60 +match_confidence_threshold = 0.6 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..016fa5b --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module veola + +go 1.25.0 + +require ( + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/a-h/templ v0.3.1020 // indirect + github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de // indirect + github.com/alexedwards/scs/v2 v2.9.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/sys v0.44.0 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.50.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..860b7cf --- /dev/null +++ b/go.sum @@ -0,0 +1,36 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= +github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= +github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de h1:c72K9HLu6K442et0j3BUL/9HEYaUJouLkkVANdmqTOo= +github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss= +github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= +github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= diff --git a/internal/apify/client.go b/internal/apify/client.go new file mode 100644 index 0000000..b94b476 --- /dev/null +++ b/internal/apify/client.go @@ -0,0 +1,152 @@ +package apify + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + apiBase = "https://api.apify.com/v2" + pollEvery = 3 * time.Second + pollTimeout = 5 * time.Minute +) + +// Client is a thin wrapper around the Apify run-and-fetch lifecycle. +type Client struct { + APIKey string + HTTP *http.Client +} + +func New(apiKey string) *Client { + return &Client{ + APIKey: apiKey, + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +type runResponse struct { + Data struct { + ID string `json:"id"` + Status string `json:"status"` + DefaultDatasetID string `json:"defaultDatasetId"` + } `json:"data"` +} + +// Run starts an actor run, waits for SUCCEEDED, and returns dataset items as raw JSON. +func (c *Client) Run(ctx context.Context, actorID string, input any) ([]json.RawMessage, error) { + if c.APIKey == "" { + return nil, errors.New("apify api_key not configured") + } + if actorID == "" { + return nil, errors.New("apify actor id is empty") + } + + body, err := json.Marshal(input) + if err != nil { + return nil, err + } + + // Apify URLs use "~" to separate username and actor name, never "/". + // Accept either form in config and normalize before path-escaping. + urlActorID := strings.ReplaceAll(actorID, "/", "~") + startURL := fmt.Sprintf("%s/acts/%s/runs?token=%s", apiBase, url.PathEscape(urlActorID), url.QueryEscape(c.APIKey)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, startURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("start run: %w", err) + } + var runResp runResponse + if err := decodeJSON(resp, &runResp); err != nil { + return nil, fmt.Errorf("start run: %w", err) + } + if runResp.Data.ID == "" { + return nil, errors.New("start run: missing run id") + } + + deadline := time.Now().Add(pollTimeout) + pollCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + + status, datasetID, err := c.waitForRun(pollCtx, runResp.Data.ID) + if err != nil { + return nil, err + } + if status != "SUCCEEDED" { + return nil, fmt.Errorf("apify run terminated with status %s", status) + } + + return c.fetchDataset(ctx, datasetID) +} + +func (c *Client) waitForRun(ctx context.Context, runID string) (string, string, error) { + pollURL := fmt.Sprintf("%s/actor-runs/%s?token=%s", apiBase, url.PathEscape(runID), url.QueryEscape(c.APIKey)) + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil) + if err != nil { + return "", "", err + } + resp, err := c.HTTP.Do(req) + if err != nil { + return "", "", fmt.Errorf("poll run: %w", err) + } + var r runResponse + if err := decodeJSON(resp, &r); err != nil { + return "", "", fmt.Errorf("poll run: %w", err) + } + switch r.Data.Status { + case "SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT": + return r.Data.Status, r.Data.DefaultDatasetID, nil + } + select { + case <-ctx.Done(): + return "", "", ctx.Err() + case <-time.After(pollEvery): + } + } +} + +func (c *Client) fetchDataset(ctx context.Context, datasetID string) ([]json.RawMessage, error) { + if datasetID == "" { + return nil, errors.New("missing dataset id") + } + dsURL := fmt.Sprintf("%s/datasets/%s/items?clean=true&format=json&token=%s", apiBase, url.PathEscape(datasetID), url.QueryEscape(c.APIKey)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, dsURL, nil) + if err != nil { + return nil, err + } + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch dataset: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("dataset returned %d: %s", resp.StatusCode, string(b)) + } + var items []json.RawMessage + if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { + return nil, fmt.Errorf("decode dataset: %w", err) + } + return items, nil +} + +func decodeJSON(resp *http.Response, dst any) error { + defer resp.Body.Close() + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("http %d: %s", resp.StatusCode, string(b)) + } + return json.NewDecoder(resp.Body).Decode(dst) +} diff --git a/internal/apify/types.go b/internal/apify/types.go new file mode 100644 index 0000000..90a70c4 --- /dev/null +++ b/internal/apify/types.go @@ -0,0 +1,313 @@ +package apify + +import ( + "encoding/json" + "strconv" + "strings" +) + +// ActiveListingInput is the input schema for `automation-lab/ebay-scraper`. +// The actor accepts keyword searches and standard filters; it targets +// ebay.com only (no per-marketplace routing in the actor itself), so +// non-US marketplaces won't return useful results with this actor. +type ActiveListingInput struct { + SearchQueries []string `json:"searchQueries"` + MaxProductsPerSearch int `json:"maxProductsPerSearch,omitempty"` + MaxSearchPages int `json:"maxSearchPages,omitempty"` + Sort string `json:"sort,omitempty"` + ListingType string `json:"listingType,omitempty"` + Condition []string `json:"condition,omitempty"` + MinPrice *int `json:"minPrice,omitempty"` + MaxPrice *int `json:"maxPrice,omitempty"` + ProxyConfiguration *ProxyConfiguration `json:"proxyConfiguration,omitempty"` +} + +// ProxyConfiguration is the standard apify input block for proxy routing. +// eBay (and most retail sites) return 403 to datacenter IPs; passing +// {"useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"]} works. +type ProxyConfiguration struct { + UseApifyProxy bool `json:"useApifyProxy"` + ApifyProxyGroups []string `json:"apifyProxyGroups,omitempty"` + ApifyProxyCountry string `json:"apifyProxyCountry,omitempty"` +} + +// ActiveListingResult is decoded leniently to handle multiple eBay-scraper +// actors. delicious_zebu/ebay-product-listing-scraper returns productUrl / +// imageUrl / numeric price; harvestlab/ebay-scraper used url / price / +// currency. The decoder coalesces both shapes. +type ActiveListingResult struct { + Title string `json:"title"` + Price any `json:"price"` + OriginalPrice any `json:"originalPrice"` + Currency string `json:"currency"` + URL string `json:"url"` + ProductURL string `json:"productUrl"` + Store string `json:"store"` + ImageURL string `json:"imageUrl"` + Image string `json:"image"` + Thumbnail string `json:"thumbnail"` + Images []string `json:"images"` + Condition string `json:"condition"` + ListingType string `json:"listingType"` + ShippingCost any `json:"shippingCost"` + ShippingPrice any `json:"shippingPrice"` + FreeShipping bool `json:"freeShipping"` + Marketplace string `json:"marketplace"` + MatchConfidence float64 `json:"matchConfidence"` + Availability string `json:"availability"` + WatchersCount int `json:"watchersCount"` + QuantitySold int `json:"quantitySold"` +} + +type SoldListingInput struct { + Query string `json:"query"` + Marketplace string `json:"marketplace,omitempty"` + MaxResults int `json:"maxResults,omitempty"` + DaysBack int `json:"daysBack,omitempty"` + ProxyConfiguration *ProxyConfiguration `json:"proxyConfiguration,omitempty"` +} + +type SoldListingResult struct { + Title string `json:"title"` + SoldPrice float64 `json:"soldPrice"` + Currency string `json:"soldCurrency"` + SoldAt string `json:"endedAt"` + Condition string `json:"condition"` + ListingType string `json:"listingType"` + ShippingPrice float64 `json:"shippingPrice"` + URL string `json:"url"` +} + +type PriceComparisonInput struct { + Query string `json:"query,omitempty"` + URL string `json:"url,omitempty"` + MatchStrictness string `json:"matchStrictness,omitempty"` + ProxyConfiguration *ProxyConfiguration `json:"proxyConfiguration,omitempty"` +} + +type PriceComparisonResult struct { + Title string `json:"title"` + Price float64 `json:"price"` + Currency string `json:"currency"` + URL string `json:"url"` + Store string `json:"store"` + ImageURL string `json:"imageUrl"` + Availability string `json:"availability"` + MatchConfidence float64 `json:"matchConfidence"` +} + +// YahooAuctionsJPInput targets meron1122/zenmarket-scraper. ZenMarket is a +// buyer-proxy for Yahoo Auctions JP; its scraper returns ZenMarket-proxied +// listing URLs and USD-converted prices. +type YahooAuctionsJPInput struct { + SearchTerm string `json:"searchTerm"` + CategoryID string `json:"categoryID,omitempty"` + MaxPages int `json:"maxPages,omitempty"` + MaxRemainingHours int `json:"maxRemainingHours,omitempty"` +} + +// MercariJPInput targets cloud9_ai/mercari-scraper. The actor manages its +// own proxy (Japan datacenter with residential fallback), so we do not send +// a proxyConfiguration block. +type MercariJPInput struct { + SearchKeywords []string `json:"searchKeywords,omitempty"` + ProductUrls []string `json:"productUrls,omitempty"` + Status string `json:"status,omitempty"` + SortBy string `json:"sortBy,omitempty"` + PriceMin *int `json:"priceMin,omitempty"` + PriceMax *int `json:"priceMax,omitempty"` + ItemCondition string `json:"itemCondition,omitempty"` + MaxResults int `json:"maxResults,omitempty"` +} + +// YahooAuctionsJPResult matches meron1122/zenmarket-scraper output. Prices +// are USD-converted at the ZenMarket-published rate. +type YahooAuctionsJPResult struct { + Name string `json:"name"` + CurrentPrice any `json:"current_price"` + Photos []string `json:"photos"` + URL string `json:"url"` + EndingDate string `json:"ending_date"` +} + +// UnifiedResult is the common shape produced by ParseResults regardless of +// which actor type returned the data. The scheduler consumes this. +type UnifiedResult struct { + Title string + Price float64 + Currency string + URL string + Store string + ImageURL string + Source string + MatchConfidence float64 + OutOfStock bool + // MatchedQuery records which alias from the item's query list produced + // this row. Empty for URL-only items or rows from non-search sources. + MatchedQuery string +} + +// Decode unmarshals a list of raw JSON items into UnifiedResult slices using +// the shape that matches the given source label. +func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) { + out := make([]UnifiedResult, 0, len(items)) + switch source { + case SourceActiveEbay, SourcePriceCompare: + for _, raw := range items { + var r ActiveListingResult + if err := json.Unmarshal(raw, &r); err != nil { + continue + } + url := r.URL + if url == "" { + url = r.ProductURL + } + img := r.ImageURL + if img == "" { + img = r.Image + } + if img == "" { + img = r.Thumbnail + } + if img == "" && len(r.Images) > 0 { + img = r.Images[0] + } + store := r.Store + if store == "" { + store = r.Marketplace + } + if store == "" && source == SourceActiveEbay { + store = "ebay" + } + cur := r.Currency + if cur == "" { + cur = "USD" + } + out = append(out, UnifiedResult{ + Title: r.Title, + Price: coercePrice(r.Price), + Currency: cur, + URL: url, + Store: store, + ImageURL: img, + Source: source, + MatchConfidence: r.MatchConfidence, + OutOfStock: isOOS(r.Availability), + }) + } + case SourceYahooJP: + for _, raw := range items { + var r YahooAuctionsJPResult + if err := json.Unmarshal(raw, &r); err != nil { + continue + } + img := "" + if len(r.Photos) > 0 { + img = r.Photos[0] + } + out = append(out, UnifiedResult{ + Title: r.Name, + Price: coercePrice(r.CurrentPrice), + Currency: "USD", + URL: r.URL, + Store: "yahoo-auctions-jp (via zenmarket)", + ImageURL: img, + Source: source, + }) + } + case SourceMercariJP: + // Mercari actors vary in shape; accept either price/currentPrice and title/name. + for _, raw := range items { + var generic struct { + Title string `json:"title"` + Name string `json:"name"` + Price float64 `json:"price"` + CurrentPrice float64 `json:"currentPrice"` + Currency string `json:"currency"` + URL string `json:"url"` + ImageURL string `json:"imageUrl"` + Image string `json:"image"` + Status string `json:"status"` + } + if err := json.Unmarshal(raw, &generic); err != nil { + continue + } + title := generic.Title + if title == "" { + title = generic.Name + } + price := generic.Price + if price == 0 { + price = generic.CurrentPrice + } + img := generic.ImageURL + if img == "" { + img = generic.Image + } + cur := generic.Currency + if cur == "" { + cur = "JPY" + } + out = append(out, UnifiedResult{ + Title: title, + Price: price, + Currency: cur, + URL: generic.URL, + Store: "mercari-jp", + ImageURL: img, + Source: source, + OutOfStock: isOOS(generic.Status), + }) + } + } + return out, nil +} + +const ( + SourceActiveEbay = "ebay" + SourcePriceCompare = "price-comparison" + SourceYahooJP = "yahoo-auctions-jp" + SourceMercariJP = "mercari-jp" + SourceSoldEbay = "ebay-sold" + SourceSoldYahooJP = "yahoo-auctions-jp-sold" +) + +// coercePrice accepts a price field that might be a number or a string with +// currency symbols / commas (e.g. "$24.99", "1,299.00"). Returns 0 on failure +// so FilterResults can drop the row cleanly. +func coercePrice(v any) float64 { + switch x := v.(type) { + case nil: + return 0 + case float64: + return x + case float32: + return float64(x) + case int: + return float64(x) + case int64: + return float64(x) + case string: + s := strings.Map(func(r rune) rune { + switch { + case r >= '0' && r <= '9', r == '.', r == '-': + return r + } + return -1 + }, x) + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0 + } + return f + } + return 0 +} + +func isOOS(s string) bool { + switch s { + case "out_of_stock", "OUT_OF_STOCK", "sold", "SOLD", "ended": + return true + } + return false +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..684063a --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,214 @@ +package auth + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/hex" + "errors" + "net/http" + "time" + + "github.com/alexedwards/scs/sqlite3store" + "github.com/alexedwards/scs/v2" + "golang.org/x/crypto/bcrypt" + + "veola/internal/db" + "veola/internal/models" +) + +const ( + BcryptCost = 12 + MinPasswordLen = 12 + + sessionUserIDKey = "user_id" + sessionCSRFKey = "csrf_token" + + csrfFormField = "csrf_token" + csrfHeaderName = "X-CSRF-Token" +) + +// Manager bundles session manager + DB store and serves as the auth surface. +type Manager struct { + Sessions *scs.SessionManager + Store *db.Store + hmacKey []byte +} + +func NewManager(sqlDB *sql.DB, store *db.Store, sessionSecret string) (*Manager, error) { + if len(sessionSecret) < 32 { + return nil, errors.New("session secret too short") + } + sm := scs.New() + sm.Store = sqlite3store.New(sqlDB) + sm.Lifetime = 7 * 24 * time.Hour + sm.IdleTimeout = 7 * 24 * time.Hour + sm.Cookie.Name = "veola_session" + sm.Cookie.HttpOnly = true + sm.Cookie.Path = "/" + sm.Cookie.SameSite = http.SameSiteLaxMode + sm.Cookie.Persist = true + // Cookie.Secure left false for self-hosted HTTP deployments; flip via env in deploy. + + mac := sha256.New() + mac.Write([]byte(sessionSecret)) + return &Manager{Sessions: sm, Store: store, hmacKey: mac.Sum(nil)}, nil +} + +func HashPassword(plain string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost) + if err != nil { + return "", err + } + return string(b), nil +} + +func CheckPassword(hash, plain string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil +} + +// LogIn writes the user id into the session and rotates the token. +func (m *Manager) LogIn(ctx context.Context, userID int64) error { + if err := m.Sessions.RenewToken(ctx); err != nil { + return err + } + m.Sessions.Put(ctx, sessionUserIDKey, userID) + m.Sessions.Put(ctx, sessionCSRFKey, newCSRFToken()) + return nil +} + +func (m *Manager) LogOut(ctx context.Context) error { + return m.Sessions.Destroy(ctx) +} + +func (m *Manager) UserID(ctx context.Context) int64 { + return m.Sessions.GetInt64(ctx, sessionUserIDKey) +} + +func (m *Manager) CurrentUser(ctx context.Context) (*models.User, error) { + id := m.UserID(ctx) + if id == 0 { + return nil, nil + } + return m.Store.GetUserByID(ctx, id) +} + +// ============ CSRF ============ + +func newCSRFToken() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + // extremely unlikely; fall back to a time-based token rather than crashing + return hex.EncodeToString([]byte(time.Now().String())) + } + return hex.EncodeToString(b) +} + +func (m *Manager) CSRFToken(ctx context.Context) string { + tok := m.Sessions.GetString(ctx, sessionCSRFKey) + if tok == "" { + tok = newCSRFToken() + m.Sessions.Put(ctx, sessionCSRFKey, tok) + } + return tok +} + +// CSRFFieldName is the HTML form field expected by the middleware. +func CSRFFieldName() string { return csrfFormField } + +// ============ Middleware ============ + +type ctxKey int + +const ( + ctxKeyUser ctxKey = iota +) + +func userFromContext(ctx context.Context) *models.User { + u, _ := ctx.Value(ctxKeyUser).(*models.User) + return u +} + +// CurrentUserFromRequest is the public accessor for handlers and templates. +func CurrentUserFromRequest(r *http.Request) *models.User { + return userFromContext(r.Context()) +} + +// LoadUser populates the user into the context for any logged-in session. +// Routes still need RequireAuth/RequireAdmin to gate access. +func (m *Manager) LoadUser(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, err := m.CurrentUser(r.Context()) + if err == nil && u != nil { + ctx := context.WithValue(r.Context(), ctxKeyUser, u) + r = r.WithContext(ctx) + } + next.ServeHTTP(w, r) + }) +} + +// RequireAuth redirects to /login if no user is present. Skips /login, /setup, +// /static. The setup-gate (redirect to /setup if no users exist) is applied +// at the router level via SetupGate so it can short-circuit before auth runs. +func (m *Manager) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if userFromContext(r.Context()) == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) +} + +func (m *Manager) RequireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := userFromContext(r.Context()) + if u == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + if u.Role != models.RoleAdmin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// CSRFProtect validates the CSRF token on non-idempotent requests. +func (m *Manager) CSRFProtect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + next.ServeHTTP(w, r) + return + } + expected := m.Sessions.GetString(r.Context(), sessionCSRFKey) + if expected == "" { + http.Error(w, "csrf token missing from session", http.StatusForbidden) + return + } + got := r.Header.Get(csrfHeaderName) + if got == "" { + if err := r.ParseForm(); err == nil { + got = r.PostFormValue(csrfFormField) + } + } + if subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 { + http.Error(w, "invalid csrf token", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// HMAC is exposed for non-session use cases (e.g. signed setup links). Not +// currently called by handlers but kept available since the secret is loaded. +func (m *Manager) HMAC(payload []byte) string { + h := hmac.New(sha256.New, m.hmacKey) + h.Write(payload) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e282929 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,125 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +type Config struct { + Server ServerConfig `toml:"server"` + Security SecurityConfig `toml:"security"` + Apify ApifyConfig `toml:"apify"` + Ntfy NtfyConfig `toml:"ntfy"` + Scheduler SchedulerConfig `toml:"scheduler"` +} + +type ServerConfig struct { + Port int `toml:"port"` + DBPath string `toml:"db_path"` +} + +type SecurityConfig struct { + SessionSecret string `toml:"session_secret"` + EncryptionKey string `toml:"encryption_key"` +} + +type ApifyConfig struct { + APIKey string `toml:"api_key"` + Actors ActorConfig `toml:"actors"` + Proxy ProxyConfig `toml:"proxy"` +} + +// ProxyConfig controls the proxyConfiguration block passed to apify actors +// that scrape sites which block datacenter IPs (e.g. eBay returns 403 without +// a residential proxy). +type ProxyConfig struct { + UseApifyProxy bool `toml:"use_apify_proxy"` + Groups []string `toml:"groups"` + Country string `toml:"country"` +} + +type ActorConfig struct { + ActiveListings string `toml:"active_listings"` + SoldListings string `toml:"sold_listings"` + PriceComparison string `toml:"price_comparison"` + YahooAuctionsJP string `toml:"yahoo_auctions_jp"` + YahooAuctionsJPSold string `toml:"yahoo_auctions_jp_sold"` + MercariJP string `toml:"mercari_jp"` +} + +type NtfyConfig struct { + BaseURL string `toml:"base_url"` + DefaultTopic string `toml:"default_topic"` +} + +type SchedulerConfig struct { + GlobalPollIntervalMinutes int `toml:"global_poll_interval_minutes"` + MatchConfidenceThreshold float64 `toml:"match_confidence_threshold"` +} + +func Load(path string) (*Config, error) { + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("config file not found at %s. Copy config.toml.example to that path and fill it in", path) + } else if err != nil { + return nil, fmt.Errorf("stat config: %w", err) + } + + var c Config + if _, err := toml.DecodeFile(path, &c); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + if err := c.validate(); err != nil { + return nil, err + } + return &c, nil +} + +func (c *Config) validate() error { + if len(c.Security.SessionSecret) < 32 { + return errors.New("security.session_secret must be at least 32 bytes") + } + if len(c.Security.EncryptionKey) < 32 { + return errors.New("security.encryption_key must be at least 32 bytes") + } + if c.Security.SessionSecret == c.Security.EncryptionKey { + return errors.New("security.session_secret and security.encryption_key must not be equal") + } + if c.Server.DBPath == "" { + return errors.New("server.db_path must be set") + } + dir := filepath.Dir(c.Server.DBPath) + if dir == "" { + dir = "." + } + if err := checkWritable(dir); err != nil { + return fmt.Errorf("server.db_path directory %s not writable: %w", dir, err) + } + if c.Server.Port == 0 { + c.Server.Port = 8080 + } + if c.Scheduler.GlobalPollIntervalMinutes == 0 { + c.Scheduler.GlobalPollIntervalMinutes = 60 + } + if c.Scheduler.MatchConfidenceThreshold == 0 { + c.Scheduler.MatchConfidenceThreshold = 0.6 + } + if c.Ntfy.DefaultTopic == "" { + c.Ntfy.DefaultTopic = "veola" + } + return nil +} + +func checkWritable(dir string) error { + f, err := os.CreateTemp(dir, ".veola-write-test-*") + if err != nil { + return err + } + name := f.Name() + f.Close() + return os.Remove(name) +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..5ab7bc1 --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,94 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "strings" + + "golang.org/x/crypto/hkdf" +) + +const ( + prefix = "enc:" + infoLabel = "veola-v1" + keyLen = 32 + nonceLen = 12 +) + +// DeriveKey derives a 32-byte AES key from raw key material via HKDF-SHA256. +func DeriveKey(rawKey []byte) ([]byte, error) { + if len(rawKey) < 32 { + return nil, errors.New("raw encryption key must be at least 32 bytes") + } + r := hkdf.New(sha256.New, rawKey, nil, []byte(infoLabel)) + out := make([]byte, keyLen) + if _, err := io.ReadFull(r, out); err != nil { + return nil, fmt.Errorf("derive key: %w", err) + } + return out, nil +} + +func Encrypt(key []byte, plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, nonceLen) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ct := gcm.Seal(nil, nonce, []byte(plaintext), nil) + buf := make([]byte, 0, len(nonce)+len(ct)) + buf = append(buf, nonce...) + buf = append(buf, ct...) + return prefix + base64.StdEncoding.EncodeToString(buf), nil +} + +func Decrypt(key []byte, value string) (string, error) { + if value == "" { + return "", nil + } + if !IsEncrypted(value) { + // Plaintext passthrough lets us decrypt rows that pre-date encryption + // without a separate migration. + return value, nil + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, prefix)) + if err != nil { + return "", fmt.Errorf("decode ciphertext: %w", err) + } + if len(raw) < nonceLen { + return "", errors.New("ciphertext too short") + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce, ct := raw[:nonceLen], raw[nonceLen:] + pt, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return "", fmt.Errorf("decrypt: %w", err) + } + return string(pt), nil +} + +func IsEncrypted(value string) bool { + return strings.HasPrefix(value, prefix) +} diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..562622d --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -0,0 +1,86 @@ +package crypto + +import ( + "strings" + "testing" +) + +func testKey(t *testing.T) []byte { + t.Helper() + k, err := DeriveKey([]byte("0123456789abcdef0123456789abcdef-aaa")) + if err != nil { + t.Fatal(err) + } + return k +} + +func TestRoundTrip(t *testing.T) { + k := testKey(t) + cases := []string{ + "hello", + "", + "ツインビー グラディウス パロディウス", + strings.Repeat("a", 4096), + "line1\nline2\ttab", + } + for _, pt := range cases { + ct, err := Encrypt(k, pt) + if err != nil { + t.Fatalf("encrypt %q: %v", pt, err) + } + if pt != "" && !IsEncrypted(ct) { + t.Errorf("expected enc: prefix on %q", ct) + } + got, err := Decrypt(k, ct) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + if got != pt { + t.Errorf("round-trip mismatch: got %q want %q", got, pt) + } + } +} + +func TestNonceUnique(t *testing.T) { + k := testKey(t) + a, _ := Encrypt(k, "same plaintext") + b, _ := Encrypt(k, "same plaintext") + if a == b { + t.Error("two encryptions of the same plaintext produced identical ciphertext (nonce not random)") + } +} + +func TestTamperRejected(t *testing.T) { + k := testKey(t) + ct, _ := Encrypt(k, "secret") + tampered := ct[:len(ct)-2] + "AA" + if _, err := Decrypt(k, tampered); err == nil { + t.Error("expected tampered ciphertext to fail decryption") + } +} + +func TestWrongKeyRejected(t *testing.T) { + k1 := testKey(t) + k2, _ := DeriveKey([]byte("a-different-32-byte-key-aaaaaaaaaaaa")) + ct, _ := Encrypt(k1, "secret") + if _, err := Decrypt(k2, ct); err == nil { + t.Error("expected decryption with wrong key to fail") + } +} + +func TestPlaintextPassthrough(t *testing.T) { + k := testKey(t) + got, err := Decrypt(k, "not-encrypted") + if err != nil { + t.Fatal(err) + } + if got != "not-encrypted" { + t.Errorf("plaintext passthrough failed: %q", got) + } +} + +func TestDeriveKeyRequiresMinLength(t *testing.T) { + if _, err := DeriveKey([]byte("too short")); err == nil { + t.Error("expected error on short key material") + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..ff82b71 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,65 @@ +package db + +import ( + "database/sql" + _ "embed" + "fmt" + + _ "modernc.org/sqlite" +) + +//go:embed schema.sql +var schemaSQL string + +func Open(path string) (*sql.DB, error) { + dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)", path) + conn, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + if err := conn.Ping(); err != nil { + conn.Close() + return nil, fmt.Errorf("ping sqlite: %w", err) + } + if _, err := conn.Exec(schemaSQL); err != nil { + conn.Close() + return nil, fmt.Errorf("apply schema: %w", err) + } + if err := addColumnIfMissing(conn, "items", "min_price", "REAL"); err != nil { + conn.Close() + return nil, err + } + if err := addColumnIfMissing(conn, "items", "exclude_keywords", "TEXT"); err != nil { + conn.Close() + return nil, err + } + if err := addColumnIfMissing(conn, "results", "matched_query", "TEXT"); err != nil { + conn.Close() + return nil, err + } + return conn, nil +} + +func addColumnIfMissing(conn *sql.DB, table, column, typ string) error { + rows, err := conn.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, table)) + if err != nil { + return fmt.Errorf("inspect %s: %w", table, err) + } + defer rows.Close() + for rows.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return err + } + if name == column { + return nil + } + } + if _, err := conn.Exec(fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s %s`, table, column, typ)); err != nil { + return fmt.Errorf("add column %s.%s: %w", table, column, err) + } + return nil +} diff --git a/internal/db/dedup_test.go b/internal/db/dedup_test.go new file mode 100644 index 0000000..885986d --- /dev/null +++ b/internal/db/dedup_test.go @@ -0,0 +1,97 @@ +package db + +import ( + "context" + "os" + "path/filepath" + "testing" + + "veola/internal/crypto" + "veola/internal/models" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + dir := t.TempDir() + conn, err := Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close() }) + key, _ := crypto.DeriveKey([]byte("0123456789abcdef0123456789abcdef-aaa")) + return NewStore(conn, key) +} + +func TestDedupByItemAndURL(t *testing.T) { + if os.Getenv("CI_SKIP_SQLITE") != "" { + t.Skip() + } + s := newTestStore(t) + ctx := context.Background() + + id, err := s.CreateItem(ctx, &models.Item{ + Name: "TwinBee", NtfyTopic: "veola", Active: true, + PollIntervalMinutes: 60, NtfyPriority: "default", + }) + if err != nil { + t.Fatal(err) + } + + r := &models.Result{ + ItemID: id, + Title: "TwinBee Famicom", + Currency: "USD", + URL: "https://example.com/listing/1", + } + if _, err := s.InsertResult(ctx, r); err != nil { + t.Fatal(err) + } + + exists, err := s.ResultExists(ctx, id, "https://example.com/listing/1") + if err != nil { + t.Fatal(err) + } + if !exists { + t.Error("expected result to be detected as duplicate") + } + + missing, _ := s.ResultExists(ctx, id, "https://example.com/listing/2") + if missing { + t.Error("expected unknown URL to not be flagged as duplicate") + } + + // Different item, same URL should not collide. + id2, _ := s.CreateItem(ctx, &models.Item{Name: "Other", NtfyTopic: "veola", PollIntervalMinutes: 60, NtfyPriority: "default"}) + other, _ := s.ResultExists(ctx, id2, "https://example.com/listing/1") + if other { + t.Error("dedup should be scoped to item_id") + } + + // Empty URL should not collide. + emptyExists, _ := s.ResultExists(ctx, id, "") + if emptyExists { + t.Error("empty URL should never be flagged as duplicate") + } +} + +func TestCJKRoundTrip(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + id, err := s.CreateItem(ctx, &models.Item{ + Name: "ツインビー", + SearchQuery: "ツインビー グラディウス パロディウス", + NtfyTopic: "veola", + Active: true, + PollIntervalMinutes: 60, NtfyPriority: "default", + }) + if err != nil { + t.Fatal(err) + } + got, err := s.GetItem(ctx, id) + if err != nil { + t.Fatal(err) + } + if got.Name != "ツインビー" || got.SearchQuery != "ツインビー グラディウス パロディウス" { + t.Errorf("CJK round-trip failed: name=%q query=%q", got.Name, got.SearchQuery) + } +} diff --git a/internal/db/queries.go b/internal/db/queries.go new file mode 100644 index 0000000..ffb7c04 --- /dev/null +++ b/internal/db/queries.go @@ -0,0 +1,747 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "veola/internal/crypto" + "veola/internal/models" +) + +// Store wraps a *sql.DB with the encryption key used for column-level crypto. +type Store struct { + DB *sql.DB + Key []byte +} + +func NewStore(db *sql.DB, key []byte) *Store { + return &Store{DB: db, Key: key} +} + +// enc encrypts plaintext, logging and returning empty string on failure. +func (s *Store) enc(plain string) string { + if plain == "" { + return "" + } + v, err := crypto.Encrypt(s.Key, plain) + if err != nil { + slog.Error("encrypt failed", "err", err) + return "" + } + return v +} + +// dec decrypts a value; on failure returns "" per spec line 333. +func (s *Store) dec(v string) string { + if v == "" { + return "" + } + out, err := crypto.Decrypt(s.Key, v) + if err != nil { + slog.Error("decrypt failed", "err", err) + return "" + } + return out +} + +func nullStr(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} + +func nullFloat(f *float64) sql.NullFloat64 { + if f == nil { + return sql.NullFloat64{} + } + return sql.NullFloat64{Float64: *f, Valid: true} +} + +func nullTime(t *time.Time) sql.NullTime { + if t == nil { + return sql.NullTime{} + } + return sql.NullTime{Time: *t, Valid: true} +} + +func ptrFloat(f sql.NullFloat64) *float64 { + if !f.Valid { + return nil + } + v := f.Float64 + return &v +} + +func ptrTime(t sql.NullTime) *time.Time { + if !t.Valid { + return nil + } + v := t.Time + return &v +} + +// ============ users ============ + +func (s *Store) UserCount(ctx context.Context) (int, error) { + var n int + err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n) + return n, err +} + +func (s *Store) CreateUser(ctx context.Context, username, hash string, role models.Role) (int64, error) { + res, err := s.DB.ExecContext(ctx, + `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`, + username, hash, string(role)) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (s *Store) GetUserByUsername(ctx context.Context, username string) (*models.User, error) { + row := s.DB.QueryRowContext(ctx, + `SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`, + username) + var u models.User + var role string + if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + u.Role = models.Role(role) + return &u, nil +} + +func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error) { + row := s.DB.QueryRowContext(ctx, + `SELECT id, username, password_hash, role, created_at FROM users WHERE id = ?`, id) + var u models.User + var role string + if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + u.Role = models.Role(role) + return &u, nil +} + +func (s *Store) ListUsers(ctx context.Context) ([]models.User, error) { + rows, err := s.DB.QueryContext(ctx, + `SELECT id, username, password_hash, role, created_at FROM users ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.User + for rows.Next() { + var u models.User + var role string + if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil { + return nil, err + } + u.Role = models.Role(role) + out = append(out, u) + } + return out, rows.Err() +} + +func (s *Store) UpdateUserPassword(ctx context.Context, id int64, hash string) error { + _, err := s.DB.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id) + return err +} + +func (s *Store) DeleteUser(ctx context.Context, id int64) error { + _, err := s.DB.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) + return err +} + +// ============ settings ============ + +func (s *Store) GetSetting(ctx context.Context, key string) (string, error) { + var v sql.NullString + err := s.DB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", err + } + if !v.Valid { + return "", nil + } + return s.dec(v.String), nil +} + +func (s *Store) GetAllSettings(ctx context.Context) (map[string]string, error) { + rows, err := s.DB.QueryContext(ctx, `SELECT key, value FROM settings`) + if err != nil { + return nil, err + } + defer rows.Close() + out := make(map[string]string) + for rows.Next() { + var k string + var v sql.NullString + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + if v.Valid { + out[k] = s.dec(v.String) + } else { + out[k] = "" + } + } + return out, rows.Err() +} + +func (s *Store) SetSetting(ctx context.Context, key, value string) error { + enc := s.enc(value) + _, err := s.DB.ExecContext(ctx, ` + INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP + `, key, enc) + return err +} + +// ============ items ============ + +func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) { + res, err := s.DB.ExecContext(ctx, ` + INSERT INTO items ( + name, search_query, url, category, target_price, ntfy_topic, ntfy_priority, + poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords, + listing_type, + actor_active, actor_sold, actor_price_compare, use_price_comparison, + active, best_price, best_price_store, best_price_url, best_price_image_url, + best_price_title, last_polled_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category), + nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority, + it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock), + nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)), + nullStr(it.ListingType), + nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare), + boolToInt(it.UsePriceComparison), boolToInt(it.Active), + nullFloat(it.BestPrice), nullStr(it.BestPriceStore), + nullStr(s.enc(it.BestPriceURL)), nullStr(s.enc(it.BestPriceImageURL)), + nullStr(s.enc(it.BestPriceTitle)), nullTime(it.LastPolledAt), + ) + if err != nil { + return 0, err + } + id, err := res.LastInsertId() + if err != nil { + return 0, err + } + if err := s.SetItemMarketplaces(ctx, id, it.Marketplaces); err != nil { + return 0, err + } + return id, nil +} + +func (s *Store) UpdateItem(ctx context.Context, it *models.Item) error { + if _, err := s.DB.ExecContext(ctx, ` + UPDATE items SET + name = ?, search_query = ?, url = ?, category = ?, target_price = ?, + ntfy_topic = ?, ntfy_priority = ?, poll_interval_minutes = ?, + include_out_of_stock = ?, min_price = ?, exclude_keywords = ?, + listing_type = ?, + actor_active = ?, actor_sold = ?, actor_price_compare = ?, + use_price_comparison = ?, active = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category), + nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority, + it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock), + nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)), + nullStr(it.ListingType), + nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare), + boolToInt(it.UsePriceComparison), boolToInt(it.Active), + it.ID, + ); err != nil { + return err + } + return s.SetItemMarketplaces(ctx, it.ID, it.Marketplaces) +} + +// SetItemMarketplaces replaces the marketplace list for an item. Order is +// preserved via the `position` column. +func (s *Store) SetItemMarketplaces(ctx context.Context, itemID int64, markets []string) error { + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `DELETE FROM item_marketplaces WHERE item_id = ?`, itemID); err != nil { + return err + } + for i, m := range markets { + if m == "" { + continue + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO item_marketplaces (item_id, position, marketplace) VALUES (?, ?, ?)`, + itemID, i, m); err != nil { + return err + } + } + return tx.Commit() +} + +// getItemMarketplaces returns the ordered marketplace list for one item. +func (s *Store) getItemMarketplaces(ctx context.Context, itemID int64) ([]string, error) { + rows, err := s.DB.QueryContext(ctx, + `SELECT marketplace FROM item_marketplaces WHERE item_id = ? ORDER BY position`, itemID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var m string + if err := rows.Scan(&m); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// loadMarketplacesForItems bulk-loads marketplaces for a list of items in one +// query. Returns a map keyed by item ID. +func (s *Store) loadMarketplacesForItems(ctx context.Context, ids []int64) (map[int64][]string, error) { + out := make(map[int64][]string, len(ids)) + if len(ids) == 0 { + return out, nil + } + placeholders := make([]string, len(ids)) + args := make([]any, len(ids)) + for i, id := range ids { + placeholders[i] = "?" + args[i] = id + } + q := `SELECT item_id, marketplace FROM item_marketplaces WHERE item_id IN (` + + strings.Join(placeholders, ",") + `) ORDER BY item_id, position` + rows, err := s.DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id int64 + var m string + if err := rows.Scan(&id, &m); err != nil { + return nil, err + } + out[id] = append(out[id], m) + } + return out, rows.Err() +} + +func (s *Store) SetItemActive(ctx context.Context, id int64, active bool) error { + _, err := s.DB.ExecContext(ctx, + `UPDATE items SET active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + boolToInt(active), id) + return err +} + +func (s *Store) DeleteItem(ctx context.Context, id int64) error { + _, err := s.DB.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id) + return err +} + +func (s *Store) GetItem(ctx context.Context, id int64) (*models.Item, error) { + row := s.DB.QueryRowContext(ctx, itemSelect+` WHERE id = ?`, id) + it, err := scanItem(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + s.decryptItem(it) + markets, err := s.getItemMarketplaces(ctx, id) + if err != nil { + return nil, err + } + it.Marketplaces = markets + return it, nil +} + +func (s *Store) ListItems(ctx context.Context) ([]models.Item, error) { + return s.listItemsWhere(ctx, itemSelect+` ORDER BY name COLLATE NOCASE`) +} + +func (s *Store) ListActiveItems(ctx context.Context) ([]models.Item, error) { + return s.listItemsWhere(ctx, itemSelect+` WHERE active = 1 ORDER BY id`) +} + +func (s *Store) listItemsWhere(ctx context.Context, q string, args ...any) ([]models.Item, error) { + rows, err := s.DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.Item + var ids []int64 + for rows.Next() { + it, err := scanItem(rows) + if err != nil { + return nil, err + } + s.decryptItem(it) + out = append(out, *it) + ids = append(ids, it.ID) + } + if err := rows.Err(); err != nil { + return nil, err + } + markets, err := s.loadMarketplacesForItems(ctx, ids) + if err != nil { + return nil, err + } + for i := range out { + out[i].Marketplaces = markets[out[i].ID] + } + return out, nil +} + +func (s *Store) ListCategories(ctx context.Context) ([]string, error) { + rows, err := s.DB.QueryContext(ctx, `SELECT DISTINCT category FROM items WHERE category IS NOT NULL AND category != '' ORDER BY category COLLATE NOCASE`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var c string + if err := rows.Scan(&c); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// UpdateItemPollResult writes best-price fields, last_polled_at, last_poll_error. +func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models.Item, errMsg string) error { + var ( + bestPrice sql.NullFloat64 + bestStore, bestURL, bestImage, bestTitle, errField sql.NullString + ) + if best != nil { + bestPrice = nullFloat(best.BestPrice) + bestStore = nullStr(best.BestPriceStore) + bestURL = nullStr(s.enc(best.BestPriceURL)) + bestImage = nullStr(s.enc(best.BestPriceImageURL)) + bestTitle = nullStr(s.enc(best.BestPriceTitle)) + } + if errMsg != "" { + errField = nullStr(s.enc(errMsg)) + } + _, err := s.DB.ExecContext(ctx, ` + UPDATE items SET + best_price = ?, best_price_store = ?, best_price_url = ?, + best_price_image_url = ?, best_price_title = ?, + last_polled_at = CURRENT_TIMESTAMP, last_poll_error = ? + WHERE id = ? + `, bestPrice, bestStore, bestURL, bestImage, bestTitle, errField, id) + return err +} + +const itemSelect = ` + SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority, + poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords, + listing_type, + actor_active, actor_sold, actor_price_compare, use_price_comparison, + active, last_polled_at, last_poll_error, best_price, best_price_store, + best_price_url, best_price_image_url, best_price_title, created_at, updated_at + FROM items +` + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanItem(r rowScanner) (*models.Item, error) { + var ( + it models.Item + searchQuery, urlS, category, listingType sql.NullString + excludeKw sql.NullString + actorA, actorS, actorP sql.NullString + ntfyTopic, lastPollErr sql.NullString + bestStore, bestURL, bestImage, bestTitle sql.NullString + targetPrice, minPrice, bestPrice sql.NullFloat64 + includeOOS, usePC, active int + lastPolledAt sql.NullTime + ) + if err := r.Scan( + &it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority, + &it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw, + &listingType, + &actorA, &actorS, &actorP, &usePC, + &active, &lastPolledAt, &lastPollErr, &bestPrice, &bestStore, + &bestURL, &bestImage, &bestTitle, &it.CreatedAt, &it.UpdatedAt, + ); err != nil { + return nil, err + } + it.ExcludeKeywords = excludeKw.String + it.MinPrice = ptrFloat(minPrice) + it.SearchQuery = searchQuery.String + it.URL = urlS.String + it.Category = category.String + it.ListingType = listingType.String + it.ActorActive = actorA.String + it.ActorSold = actorS.String + it.ActorPriceCompare = actorP.String + it.NtfyTopic = ntfyTopic.String + it.LastPollError = lastPollErr.String + it.BestPriceStore = bestStore.String + it.BestPriceURL = bestURL.String + it.BestPriceImageURL = bestImage.String + it.BestPriceTitle = bestTitle.String + it.TargetPrice = ptrFloat(targetPrice) + it.BestPrice = ptrFloat(bestPrice) + it.IncludeOutOfStock = includeOOS != 0 + it.UsePriceComparison = usePC != 0 + it.Active = active != 0 + it.LastPolledAt = ptrTime(lastPolledAt) + return &it, nil +} + +func (s *Store) decryptItem(it *models.Item) *models.Item { + it.SearchQuery = s.dec(it.SearchQuery) + it.ExcludeKeywords = s.dec(it.ExcludeKeywords) + it.NtfyTopic = s.dec(it.NtfyTopic) + it.LastPollError = s.dec(it.LastPollError) + it.BestPriceURL = s.dec(it.BestPriceURL) + it.BestPriceImageURL = s.dec(it.BestPriceImageURL) + it.BestPriceTitle = s.dec(it.BestPriceTitle) + return it +} + +// ============ results ============ + +func (s *Store) InsertResult(ctx context.Context, r *models.Result) (int64, error) { + res, err := s.DB.ExecContext(ctx, ` + INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, + r.ItemID, s.enc(r.Title), nullFloat(r.Price), r.Currency, + nullStr(r.URL), nullStr(r.Source), s.enc(r.ImageURL), + nullStr(s.enc(r.MatchedQuery)), + boolToInt(r.Alerted), + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// ResultExists returns true if a row with this item_id and url already exists. +// URL is stored as plaintext per agreed deviation #1, so equality works. +func (s *Store) ResultExists(ctx context.Context, itemID int64, url string) (bool, error) { + if url == "" { + return false, nil + } + var n int + err := s.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM results WHERE item_id = ? AND url = ?`, itemID, url, + ).Scan(&n) + if err != nil { + return false, err + } + return n > 0, nil +} + +func (s *Store) MarkResultAlerted(ctx context.Context, id int64) error { + _, err := s.DB.ExecContext(ctx, `UPDATE results SET alerted = 1 WHERE id = ?`, id) + return err +} + +type ResultsQuery struct { + ItemID int64 // 0 = all items + Limit int + Offset int + Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc" +} + +func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Result, error) { + order := `found_at DESC` + switch q.Order { + case "price_asc": + order = `price ASC NULLS LAST` + case "price_desc": + order = `price DESC NULLS LAST` + case "found_asc": + order = `found_at ASC` + } + limit := q.Limit + if limit <= 0 { + limit = 20 + } + args := []any{} + where := "" + if q.ItemID != 0 { + where = `WHERE item_id = ?` + args = append(args, q.ItemID) + } + args = append(args, limit, q.Offset) + rows, err := s.DB.QueryContext(ctx, fmt.Sprintf(` + SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at + FROM results %s ORDER BY %s LIMIT ? OFFSET ? + `, where, order), args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.Result + for rows.Next() { + var ( + r models.Result + title, urlS, source, imageS, matchQ sql.NullString + price sql.NullFloat64 + alerted int + ) + if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt); err != nil { + return nil, err + } + r.Title = s.dec(title.String) + r.URL = urlS.String + r.Source = source.String + r.ImageURL = s.dec(imageS.String) + r.MatchedQuery = s.dec(matchQ.String) + r.Price = ptrFloat(price) + r.Alerted = alerted != 0 + out = append(out, r) + } + return out, rows.Err() +} + +func (s *Store) CountResults(ctx context.Context, itemID int64) (int, error) { + var n int + q := `SELECT COUNT(*) FROM results` + args := []any{} + if itemID != 0 { + q += ` WHERE item_id = ?` + args = append(args, itemID) + } + err := s.DB.QueryRowContext(ctx, q, args...).Scan(&n) + return n, err +} + +// ============ price_history ============ + +func (s *Store) InsertPricePoint(ctx context.Context, p *models.PricePoint) error { + if p.PolledAt.IsZero() { + _, err := s.DB.ExecContext(ctx, + `INSERT INTO price_history (item_id, price, store) VALUES (?, ?, ?)`, + p.ItemID, p.Price, s.enc(p.Store)) + return err + } + _, err := s.DB.ExecContext(ctx, + `INSERT INTO price_history (item_id, price, store, polled_at) VALUES (?, ?, ?, ?)`, + p.ItemID, p.Price, s.enc(p.Store), p.PolledAt) + return 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`, + itemID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.PricePoint + for rows.Next() { + var p models.PricePoint + var store sql.NullString + if err := rows.Scan(&p.ID, &p.ItemID, &p.Price, &store, &p.PolledAt); err != nil { + return nil, err + } + p.Store = s.dec(store.String) + out = append(out, p) + } + return out, rows.Err() +} + +// ============ stats ============ + +type DashboardStats struct { + TotalItems int + ActiveItems int + ResultsToday int + AlertsToday int + PotentialSpend float64 + PricedItemCount int + UnpricedCount int + MoneySaved float64 + SavedItemCount int +} + +func (s *Store) GetDashboardStats(ctx context.Context) (*DashboardStats, error) { + d := &DashboardStats{} + queries := map[string]any{ + `SELECT COUNT(*) FROM items`: &d.TotalItems, + `SELECT COUNT(*) FROM items WHERE active = 1`: &d.ActiveItems, + `SELECT COUNT(*) FROM results WHERE found_at >= datetime('now', '-1 day')`: &d.ResultsToday, + `SELECT COUNT(*) FROM results WHERE alerted = 1 AND found_at >= datetime('now', '-1 day')`: &d.AlertsToday, + } + for q, dst := range queries { + if err := s.DB.QueryRowContext(ctx, q).Scan(dst); err != nil { + return nil, err + } + } + if err := s.DB.QueryRowContext(ctx, ` + SELECT COALESCE(SUM(best_price), 0), COUNT(*) + FROM items WHERE active = 1 AND best_price IS NOT NULL + `).Scan(&d.PotentialSpend, &d.PricedItemCount); err != nil { + return nil, err + } + if err := s.DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM items WHERE active = 1 AND best_price IS NULL + `).Scan(&d.UnpricedCount); err != nil { + return nil, err + } + rows, err := s.DB.QueryContext(ctx, ` + SELECT i.best_price, AVG(p.price) AS avg_price + FROM items i + JOIN price_history p ON p.item_id = i.id + WHERE i.active = 1 AND i.best_price IS NOT NULL + GROUP BY i.id + `) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var bp, avg float64 + if err := rows.Scan(&bp, &avg); err != nil { + return nil, err + } + if bp < avg { + d.MoneySaved += avg - bp + d.SavedItemCount++ + } + } + return d, rows.Err() +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/db/schema.sql b/internal/db/schema.sql new file mode 100644 index 0000000..ad7f692 --- /dev/null +++ b/internal/db/schema.sql @@ -0,0 +1,99 @@ +PRAGMA journal_mode=WAL; +PRAGMA foreign_keys=ON; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + search_query TEXT, + url TEXT, + category TEXT, + target_price REAL, + ntfy_topic TEXT NOT NULL, + ntfy_priority TEXT DEFAULT 'default', + poll_interval_minutes INTEGER DEFAULT 60, + include_out_of_stock INTEGER DEFAULT 0, + min_price REAL, + exclude_keywords TEXT, + listing_type TEXT, + actor_active TEXT, + actor_sold TEXT, + actor_price_compare TEXT, + use_price_comparison INTEGER DEFAULT 0, + active INTEGER DEFAULT 1, + last_polled_at DATETIME, + last_poll_error TEXT, + best_price REAL, + best_price_store TEXT, + best_price_url TEXT, + best_price_image_url TEXT, + best_price_title TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_items_active ON items(active); + +CREATE TABLE IF NOT EXISTS item_marketplaces ( + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + marketplace TEXT NOT NULL, + PRIMARY KEY (item_id, position) +); + +CREATE INDEX IF NOT EXISTS idx_item_marketplaces_item ON item_marketplaces(item_id); + +CREATE TABLE IF NOT EXISTS results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + title TEXT, + price REAL, + currency TEXT NOT NULL, + url TEXT, + source TEXT, + image_url TEXT, + matched_query TEXT, + alerted INTEGER DEFAULT 0, + found_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_results_item ON results(item_id, found_at DESC); +CREATE INDEX IF NOT EXISTS idx_results_dedup ON results(item_id, url); + +CREATE TABLE IF NOT EXISTS price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + price REAL NOT NULL, + store TEXT, + polled_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_price_history_item ON price_history(item_id, polled_at DESC); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +INSERT OR IGNORE INTO settings (key, value) VALUES + ('apify_api_key', ''), + ('ntfy_base_url', ''), + ('ntfy_default_topic', 'veola'), + ('global_poll_interval_minutes', '60'), + ('match_confidence_threshold', '0.6'); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + data BLOB NOT NULL, + expiry REAL NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expiry); diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..9b97cb6 --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "net/http" + "strings" + + "veola/internal/auth" + "veola/internal/models" + "veola/templates" +) + +func (a *App) GetLogin(w http.ResponseWriter, r *http.Request) { + if auth.CurrentUserFromRequest(r) != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + render(w, r, templates.Login(templates.LoginData{ + Page: a.page(r, "Sign in", ""), + })) +} + +func (a *App) PostLogin(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + username := strings.TrimSpace(r.PostFormValue("username")) + password := r.PostFormValue("password") + u, err := a.Store.GetUserByUsername(r.Context(), username) + if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, password) { + render(w, r, templates.Login(templates.LoginData{ + Page: a.page(r, "Sign in", ""), + Error: "Invalid username or password", + Username: username, + })) + return + } + if err := a.Auth.LogIn(r.Context(), u.ID); err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (a *App) PostLogout(w http.ResponseWriter, r *http.Request) { + _ = a.Auth.LogOut(r.Context()) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (a *App) GetSetup(w http.ResponseWriter, r *http.Request) { + n, err := a.Store.UserCount(r.Context()) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + if n > 0 { + http.NotFound(w, r) + return + } + render(w, r, templates.Setup(templates.SetupData{ + Page: a.page(r, "Setup", ""), + })) +} + +func (a *App) PostSetup(w http.ResponseWriter, r *http.Request) { + n, err := a.Store.UserCount(r.Context()) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + if n > 0 { + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + username := strings.TrimSpace(r.PostFormValue("username")) + password := r.PostFormValue("password") + confirm := r.PostFormValue("password_confirm") + errMsg := "" + switch { + case username == "": + errMsg = "Username is required" + case len(password) < auth.MinPasswordLen: + errMsg = "Password must be at least 12 characters" + case password != confirm: + errMsg = "Passwords do not match" + } + if errMsg != "" { + render(w, r, templates.Setup(templates.SetupData{ + Page: a.page(r, "Setup", ""), + Error: errMsg, + Username: username, + })) + return + } + hash, err := auth.HashPassword(password) + if err != nil { + http.Error(w, "hash error", http.StatusInternalServerError) + return + } + if _, err := a.Store.CreateUser(r.Context(), username, hash, models.RoleAdmin); err != nil { + render(w, r, templates.Setup(templates.SetupData{ + Page: a.page(r, "Setup", ""), + Error: "Could not create user: " + err.Error(), + Username: username, + })) + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) +} diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go new file mode 100644 index 0000000..825df01 --- /dev/null +++ b/internal/handlers/dashboard.go @@ -0,0 +1,106 @@ +package handlers + +import ( + "database/sql" + "net/http" + "time" + + "veola/internal/db" + "veola/templates" +) + +func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) { + d, err := a.dashboardData(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + render(w, r, templates.Dashboard(d)) +} + +func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) { + d, err := a.dashboardData(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // Render only the inner block by reusing the full body component; the + // outer hx-swap="outerHTML" replaces the same wrapper. The full Dashboard + // template is overkill but keeps a single source of truth. + render(w, r, templates.Dashboard(d)) +} + +func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) { + stats, err := a.Store.GetDashboardStats(r.Context()) + if err != nil { + return templates.DashboardData{}, err + } + results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20}) + if err != nil { + return templates.DashboardData{}, err + } + itemNames := map[int64]string{} + all, _ := a.Store.ListItems(r.Context()) + for _, it := range all { + itemNames[it.ID] = it.Name + } + rrs := make([]templates.ResultRow, 0, len(results)) + for _, r := range results { + rrs = append(rrs, templates.ResultRow{ + ItemID: r.ItemID, + ItemName: itemNames[r.ItemID], + Title: r.Title, + Price: r.Price, + Currency: r.Currency, + Source: r.Source, + URL: r.URL, + FoundAt: r.FoundAt, + Alerted: r.Alerted, + }) + } + alerts, err := alertsRecent(a, r, itemNames) + if err != nil { + return templates.DashboardData{}, err + } + return templates.DashboardData{ + Page: a.page(r, "Dashboard", "dashboard"), + Stats: stats, + RecentResults: rrs, + RecentAlerts: alerts, + }, nil +} + +func alertsRecent(a *App, r *http.Request, itemNames map[int64]string) ([]templates.AlertRow, error) { + rows, err := a.Store.DB.QueryContext(r.Context(), ` + SELECT item_id, price, currency, found_at FROM results + WHERE alerted = 1 ORDER BY found_at DESC LIMIT 5 + `) + if err != nil { + return nil, err + } + defer rows.Close() + var out []templates.AlertRow + for rows.Next() { + var ( + itemID int64 + price sql.NullFloat64 + currency string + foundAt time.Time + ) + if err := rows.Scan(&itemID, &price, ¤cy, &foundAt); err != nil { + return nil, err + } + var p *float64 + if price.Valid { + v := price.Float64 + p = &v + } + out = append(out, templates.AlertRow{ + ItemName: itemNames[itemID], + Price: p, + Currency: currency, + FoundAt: foundAt, + }) + } + return out, rows.Err() +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..e3e3fc7 --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,146 @@ +// Package handlers wires HTTP routes for Veola. Each file in the package owns +// a related cluster of routes; this file holds the shared App container and +// helper functions used across all handlers. +package handlers + +import ( + "context" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/a-h/templ" + "github.com/go-chi/chi/v5" + + "veola/internal/apify" + "veola/internal/auth" + "veola/internal/config" + "veola/internal/db" + "veola/internal/ntfy" + "veola/internal/scheduler" + "veola/templates" +) + +type App struct { + Cfg *config.Config + Store *db.Store + Auth *auth.Manager + Apify *apify.Client + Ntfy *ntfy.Client + Scheduler *scheduler.Scheduler + Preview *PreviewCache +} + +func New(cfg *config.Config, store *db.Store, am *auth.Manager, ap *apify.Client, nt *ntfy.Client, sc *scheduler.Scheduler) *App { + return &App{ + Cfg: cfg, Store: store, Auth: am, + Apify: ap, Ntfy: nt, Scheduler: sc, + Preview: NewPreviewCache(10 * time.Minute), + } +} + +// Routes returns the chi router with everything wired up. +func (a *App) Routes() http.Handler { + r := chi.NewRouter() + fs := http.FileServer(http.Dir("./static")) + r.Handle("/static/*", http.StripPrefix("/static/", fs)) + + // All other routes pass through session loading + setup gate. + r.Group(func(r chi.Router) { + r.Use(a.Auth.Sessions.LoadAndSave) + r.Use(a.Auth.LoadUser) + r.Use(a.setupGate) + + // Public auth pages. + r.Get("/login", a.GetLogin) + r.With(a.Auth.CSRFProtect).Post("/login", a.PostLogin) + r.Get("/setup", a.GetSetup) + r.With(a.Auth.CSRFProtect).Post("/setup", a.PostSetup) + + // Authenticated section. + r.Group(func(r chi.Router) { + r.Use(a.Auth.RequireAuth) + r.With(a.Auth.CSRFProtect).Post("/logout", a.PostLogout) + + r.Get("/", a.GetDashboard) + r.Get("/dashboard/refresh", a.GetDashboardRefresh) + + r.Get("/items", a.GetItems) + r.Get("/items/new", a.GetNewItem) + r.With(a.Auth.CSRFProtect).Post("/items/preview", a.PostPreview) + r.With(a.Auth.CSRFProtect).Post("/items", a.PostCreateItem) + r.Get("/items/{id}/edit", a.GetEditItem) + r.With(a.Auth.CSRFProtect).Post("/items/{id}", a.PostUpdateItem) + r.With(a.Auth.CSRFProtect).Post("/items/{id}/toggle", a.PostToggleItem) + r.With(a.Auth.CSRFProtect).Post("/items/{id}/delete", a.PostDeleteItem) + r.With(a.Auth.CSRFProtect).Post("/items/{id}/run", a.PostRunItem) + r.Get("/items/{id}/error", a.GetItemError) + r.Get("/items/{id}/results", a.GetItemResults) + + r.Get("/results", a.GetGlobalResults) + + r.Get("/settings", a.GetSettings) + r.With(a.Auth.CSRFProtect).Post("/settings", a.PostSettings) + r.With(a.Auth.CSRFProtect).Post("/settings/password", a.PostPasswordChange) + r.With(a.Auth.CSRFProtect).Post("/settings/test-ntfy", a.PostTestNtfy) + r.With(a.Auth.CSRFProtect).Post("/settings/test-apify", a.PostTestApify) + r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users", a.PostCreateUser) + r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/delete", a.PostDeleteUser) + r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword) + }) + }) + return r +} + +// setupGate redirects every request to /setup if no users exist. +func (a *App) setupGate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/setup" || isStaticPath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + n, err := a.Store.UserCount(r.Context()) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + if n == 0 { + http.Redirect(w, r, "/setup", http.StatusSeeOther) + return + } + // Once at least one user exists, /setup is a 404. + next.ServeHTTP(w, r) + }) +} + +func isStaticPath(p string) bool { + return len(p) >= 8 && p[:8] == "/static/" +} + +func (a *App) page(r *http.Request, title, active string) templates.Page { + return templates.Page{ + Title: title, + Active: active, + CSRFToken: a.Auth.CSRFToken(r.Context()), + CurrentUser: auth.CurrentUserFromRequest(r), + } +} + +func render(w http.ResponseWriter, r *http.Request, c templ.Component) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := c.Render(r.Context(), w); err != nil { + slog.Error("render failed", "err", err) + } +} + +func parseInt64(s string) int64 { + n, _ := strconv.ParseInt(s, 10, 64) + return n +} + +func intParam(r *http.Request, key string) int64 { + return parseInt64(chi.URLParam(r, key)) +} + +func ctxBg() context.Context { return context.Background() } diff --git a/internal/handlers/items.go b/internal/handlers/items.go new file mode 100644 index 0000000..5d38d66 --- /dev/null +++ b/internal/handlers/items.go @@ -0,0 +1,453 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sort" + "strconv" + "strings" + + "veola/internal/apify" + "veola/internal/models" + "veola/internal/scheduler" + "veola/templates" +) + +func (a *App) GetItems(w http.ResponseWriter, r *http.Request) { + cat := r.URL.Query().Get("category") + all, err := a.Store.ListItems(r.Context()) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + var items []models.Item + for _, it := range all { + if cat == "" || it.Category == cat { + items = append(items, it) + } + } + cats, _ := a.Store.ListCategories(r.Context()) + render(w, r, templates.Items(templates.ItemsData{ + Page: a.page(r, "Items", "items"), + Items: items, + Categories: cats, + SelectedCategory: cat, + })) +} + +func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) { + cats, _ := a.Store.ListCategories(r.Context()) + render(w, r, templates.ItemForm(templates.ItemFormData{ + Page: a.page(r, "Add Item", "items"), + IsEdit: false, + Categories: cats, + Item: models.Item{ + NtfyPriority: "default", + PollIntervalMinutes: a.Cfg.Scheduler.GlobalPollIntervalMinutes, + Marketplaces: []string{"ebay.com"}, + ListingType: "all", + }, + })) +} + +func (a *App) GetEditItem(w http.ResponseWriter, r *http.Request) { + id := intParam(r, "id") + it, err := a.Store.GetItem(r.Context(), id) + if err != nil || it == nil { + http.NotFound(w, r) + return + } + cats, _ := a.Store.ListCategories(r.Context()) + render(w, r, templates.ItemForm(templates.ItemFormData{ + Page: a.page(r, "Edit "+it.Name, "items"), + IsEdit: true, + Item: *it, + Categories: cats, + })) +} + +// parseItemForm pulls form fields into a models.Item plus a list of validation +// errors. Used by preview, create, and update. +func parseItemForm(r *http.Request) (models.Item, []string) { + var it models.Item + var errs []string + if err := r.ParseForm(); err != nil { + return it, []string{"could not parse form"} + } + it.Name = strings.TrimSpace(r.PostFormValue("name")) + it.SearchQuery = strings.Join(models.SplitList(r.PostFormValue("search_query"), 10), "\n") + it.ExcludeKeywords = strings.Join(models.SplitList(r.PostFormValue("exclude_keywords"), 20), "\n") + it.URL = strings.TrimSpace(r.PostFormValue("url")) + if newCat := strings.TrimSpace(r.PostFormValue("category_new")); newCat != "" { + it.Category = newCat + } else { + it.Category = strings.TrimSpace(r.PostFormValue("category")) + } + it.NtfyTopic = strings.TrimSpace(r.PostFormValue("ntfy_topic")) + it.NtfyPriority = strings.TrimSpace(r.PostFormValue("ntfy_priority")) + if it.NtfyPriority == "" { + it.NtfyPriority = "default" + } + it.Marketplaces = collectMarketplaces(r.PostForm["marketplace"], r.PostFormValue("marketplace_custom")) + it.ListingType = strings.TrimSpace(r.PostFormValue("listing_type")) + it.ActorActive = strings.TrimSpace(r.PostFormValue("actor_active")) + it.ActorSold = strings.TrimSpace(r.PostFormValue("actor_sold")) + it.ActorPriceCompare = strings.TrimSpace(r.PostFormValue("actor_price_compare")) + it.IncludeOutOfStock = r.PostFormValue("include_out_of_stock") == "1" + it.UsePriceComparison = r.PostFormValue("use_price_comparison") == "1" + it.Active = true + + if tp := strings.TrimSpace(r.PostFormValue("target_price")); tp != "" { + if v, err := strconv.ParseFloat(tp, 64); err == nil && v >= 0 { + it.TargetPrice = &v + } + } + if mp := strings.TrimSpace(r.PostFormValue("min_price")); mp != "" { + if v, err := strconv.ParseFloat(mp, 64); err == nil && v >= 0 { + it.MinPrice = &v + } + } + if pi := strings.TrimSpace(r.PostFormValue("poll_interval_minutes")); pi != "" { + if v, err := strconv.Atoi(pi); err == nil && v > 0 { + it.PollIntervalMinutes = v + } + } + if it.PollIntervalMinutes == 0 { + it.PollIntervalMinutes = 60 + } + + if it.Name == "" { + errs = append(errs, "name is required") + } + if it.SearchQuery == "" && it.URL == "" { + errs = append(errs, "either search query or product URL is required") + } + if it.NtfyTopic == "" { + // Default to a slug of the name. + it.NtfyTopic = slugify(it.Name) + } + return it, errs +} + +// collectMarketplaces dedupes the checkbox values + custom CSV input into an +// ordered slice. Checkbox order first, then custom entries in the order the +// user typed them. +func collectMarketplaces(checked []string, custom string) []string { + seen := map[string]bool{} + var out []string + add := func(v string) { + v = strings.TrimSpace(v) + if v == "" || seen[v] { + return + } + seen[v] = true + out = append(out, v) + } + for _, v := range checked { + add(v) + } + for _, v := range strings.Split(custom, ",") { + add(v) + } + return out +} + +func slugify(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + case r == ' ', r == '_', r == '-': + b.WriteRune('-') + } + } + out := b.String() + if out == "" { + return "veola-item" + } + return out +} + +func (a *App) PostPreview(w http.ResponseWriter, r *http.Request) { + it, errs := parseItemForm(r) + if len(errs) > 0 { + render(w, r, templates.ItemPreview(templates.PreviewData{ + CSRFToken: a.Auth.CSRFToken(r.Context()), + Form: formValuesFromItem(it, r), + Error: strings.Join(errs, "; "), + })) + return + } + results, source, cached, err := a.runPreview(r.Context(), it) + if err != nil { + render(w, r, templates.ItemPreview(templates.PreviewData{ + CSRFToken: a.Auth.CSRFToken(r.Context()), + Form: formValuesFromItem(it, r), + Error: err.Error(), + })) + return + } + results = scheduler.FilterResults(results, a.Cfg.Scheduler.MatchConfidenceThreshold, it.IncludeOutOfStock) + results = scheduler.ApplyItemFilters(results, it.MinPrice, it.ExcludeKeywordsList()) + if len(results) == 0 { + render(w, r, templates.ItemPreview(templates.PreviewData{ + CSRFToken: a.Auth.CSRFToken(r.Context()), + Form: formValuesFromItem(it, r), + Empty: true, + })) + return + } + bestIdx := scheduler.PickBest(results) + minP, maxP := results[0].Price, results[0].Price + stores := map[string]struct{}{} + cur := results[0].Currency + for _, r := range results { + if r.Price < minP { + minP = r.Price + } + if r.Price > maxP { + maxP = r.Price + } + stores[r.Store] = struct{}{} + } + render(w, r, templates.ItemPreview(templates.PreviewData{ + CSRFToken: a.Auth.CSRFToken(r.Context()), + Form: formValuesFromItem(it, r), + Results: results, + BestIndex: bestIdx, + MinPrice: minP, + MaxPrice: maxP, + StoreCount: len(stores), + Cached: cached, + Currency: cur, + })) + _ = source +} + +func (a *App) runPreview(ctx context.Context, it models.Item) ([]apify.UnifiedResult, string, bool, error) { + plans := a.Scheduler.BuildPreviewInputs(it) + if len(plans) == 0 { + return nil, "", false, fmt.Errorf("no actor configured for this item") + } + previewMarket := "" + if len(it.Marketplaces) > 0 { + previewMarket = it.Marketplaces[0] + } + queries := it.SearchQueries() + sortedQ := make([]string, len(queries)) + copy(sortedQ, queries) + sort.Strings(sortedQ) + actorIDs := make([]string, 0, len(plans)) + for _, p := range plans { + actorIDs = append(actorIDs, p.ActorID()) + } + sort.Strings(actorIDs) + key := previewKey{ + Queries: strings.Join(sortedQ, "\n"), + URL: it.URL, + Marketplace: previewMarket, + ListingType: it.ListingType, + ActorIDs: strings.Join(actorIDs, ","), + MaxResults: 30, + } + if cached, src, ok := a.Preview.Get(key); ok { + return cached, src, true, nil + } + var merged []apify.UnifiedResult + primarySource := "" + for _, p := range plans { + actorID := p.ActorID() + if actorID == "" { + continue + } + raw, err := a.Apify.Run(ctx, actorID, p.Input()) + if err != nil { + slog.Warn("preview run failed", "actor", actorID, "query", p.Query(), "err", err) + continue + } + decoded, _ := apify.Decode(raw, p.Source()) + for i := range decoded { + decoded[i].MatchedQuery = p.Query() + } + usable := 0 + for _, r := range decoded { + if r.URL != "" && r.Price > 0 { + usable++ + } + } + slog.Info("preview decoded", + "marketplace", previewMarket, + "actor", actorID, + "query", p.Query(), + "raw", len(raw), + "decoded", len(decoded), + "usable", usable, + ) + if usable == 0 && len(raw) > 0 { + var sample map[string]any + if err := json.Unmarshal(raw[0], &sample); err == nil { + ks := make([]string, 0, len(sample)) + for k := range sample { + ks = append(ks, k) + } + slog.Warn("preview decoded zero usable rows; raw item keys", + "actor", actorID, + "keys", ks, + ) + } + } + merged = append(merged, decoded...) + if primarySource == "" { + primarySource = p.Source() + } + } + merged = scheduler.DedupByURL(merged) + a.Preview.Put(key, merged, primarySource) + return merged, primarySource, false, nil +} + +func formValuesFromItem(it models.Item, r *http.Request) templates.FormValues { + tp := "" + if it.TargetPrice != nil { + tp = fmt.Sprintf("%.2f", *it.TargetPrice) + } + mp := "" + if it.MinPrice != nil { + mp = fmt.Sprintf("%.2f", *it.MinPrice) + } + return templates.FormValues{ + Name: it.Name, + SearchQuery: it.SearchQuery, + URL: it.URL, + Category: it.Category, + TargetPrice: tp, + MinPrice: mp, + ExcludeKeywords: it.ExcludeKeywords, + NtfyTopic: it.NtfyTopic, + NtfyPriority: it.NtfyPriority, + PollIntervalMinutes: fmt.Sprintf("%d", it.PollIntervalMinutes), + IncludeOutOfStock: it.IncludeOutOfStock, + Marketplaces: it.Marketplaces, + ListingType: it.ListingType, + ActorActive: it.ActorActive, + ActorSold: it.ActorSold, + ActorPriceCompare: it.ActorPriceCompare, + UsePriceComparison: it.UsePriceComparison, + } +} + +func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) { + it, errs := parseItemForm(r) + if len(errs) > 0 { + http.Error(w, strings.Join(errs, "; "), http.StatusBadRequest) + return + } + id, err := a.Store.CreateItem(r.Context(), &it) + if err != nil { + http.Error(w, "could not save item: "+err.Error(), http.StatusInternalServerError) + return + } + it.ID = id + a.Scheduler.SyncItem(it) + + go func() { + bg := context.Background() + fresh, err := a.Store.GetItem(bg, id) + if err != nil || fresh == nil { + return + } + a.Scheduler.SeedSoldHistory(bg, *fresh) + a.Scheduler.RunPoll(bg, *fresh) + }() + + http.Redirect(w, r, fmt.Sprintf("/items/%d/results", id), http.StatusSeeOther) +} + +func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) { + id := intParam(r, "id") + existing, err := a.Store.GetItem(r.Context(), id) + if err != nil || existing == nil { + http.NotFound(w, r) + return + } + updated, errs := parseItemForm(r) + if len(errs) > 0 { + cats, _ := a.Store.ListCategories(r.Context()) + updated.ID = id + render(w, r, templates.ItemForm(templates.ItemFormData{ + Page: a.page(r, "Edit "+updated.Name, "items"), + IsEdit: true, + Item: updated, + Errors: errs, + Categories: cats, + })) + return + } + updated.ID = id + updated.Active = existing.Active + if err := a.Store.UpdateItem(r.Context(), &updated); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + a.Scheduler.SyncItem(updated) + http.Redirect(w, r, "/items", http.StatusSeeOther) +} + +func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) { + id := intParam(r, "id") + it, err := a.Store.GetItem(r.Context(), id) + if err != nil || it == nil { + http.NotFound(w, r) + return + } + it.Active = !it.Active + if err := a.Store.SetItemActive(r.Context(), id, it.Active); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + a.Scheduler.SyncItem(*it) + render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context()))) +} + +func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) { + id := intParam(r, "id") + if err := a.Store.DeleteItem(r.Context(), id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + a.Scheduler.RemoveItem(id) + render(w, r, templates.EmptyRow()) +} + +func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) { + id := intParam(r, "id") + it, err := a.Store.GetItem(r.Context(), id) + if err != nil || it == nil { + http.NotFound(w, r) + return + } + go a.Scheduler.RunPoll(context.Background(), *it) + // Re-render the row immediately so HTMX has something to swap in. + render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context()))) +} + +func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) { + id := intParam(r, "id") + it, err := a.Store.GetItem(r.Context(), id) + if err != nil || it == nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, "%s", htmlEscape(it.LastPollError)) +} + +func htmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """) + return r.Replace(s) +} diff --git a/internal/handlers/preview_cache.go b/internal/handlers/preview_cache.go new file mode 100644 index 0000000..38fbf9e --- /dev/null +++ b/internal/handlers/preview_cache.go @@ -0,0 +1,66 @@ +package handlers + +import ( + "sync" + "time" + + "veola/internal/apify" +) + +// previewKey caches the *raw* apify result set (post-decode, post-merge, +// pre-filter). Filters like min_price and exclude_keywords are applied after +// the cache lookup so the operator can iterate on them without burning credits. +type previewKey struct { + Queries, URL, Marketplace, ListingType, ActorIDs string + MaxResults int +} + +type previewEntry struct { + results []apify.UnifiedResult + source string + stored time.Time +} + +type PreviewCache struct { + ttl time.Duration + mu sync.Mutex + entries map[previewKey]previewEntry +} + +func NewPreviewCache(ttl time.Duration) *PreviewCache { + return &PreviewCache{ + ttl: ttl, + entries: make(map[previewKey]previewEntry), + } +} + +func (c *PreviewCache) Get(k previewKey) ([]apify.UnifiedResult, string, bool) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[k] + if !ok { + return nil, "", false + } + if time.Since(e.stored) > c.ttl { + delete(c.entries, k) + return nil, "", false + } + return e.results, e.source, true +} + +func (c *PreviewCache) Put(k previewKey, results []apify.UnifiedResult, source string) { + c.mu.Lock() + defer c.mu.Unlock() + c.entries[k] = previewEntry{results: results, source: source, stored: time.Now()} + if len(c.entries) > 64 { + c.evictExpired() + } +} + +func (c *PreviewCache) evictExpired() { + for k, e := range c.entries { + if time.Since(e.stored) > c.ttl { + delete(c.entries, k) + } + } +} diff --git a/internal/handlers/results.go b/internal/handlers/results.go new file mode 100644 index 0000000..6620999 --- /dev/null +++ b/internal/handlers/results.go @@ -0,0 +1,145 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + "time" + + "veola/internal/db" + "veola/internal/models" + "veola/internal/scheduler" + "veola/templates" +) + +const resultsPerPage = 20 + +func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) { + id := intParam(r, "id") + it, err := a.Store.GetItem(r.Context(), id) + if err != nil || it == nil { + http.NotFound(w, r) + return + } + + order := r.URL.Query().Get("order") + if order == "" { + order = "found_desc" + } + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + + total, err := a.Store.CountResults(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + totalPages := (total + resultsPerPage - 1) / resultsPerPage + if totalPages < 1 { + totalPages = 1 + } + if page > totalPages { + page = totalPages + } + + results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{ + ItemID: id, + Limit: resultsPerPage, + Offset: (page - 1) * resultsPerPage, + Order: order, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + history, err := a.Store.ListPriceHistory(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + badge := scheduler.PickBadge(*it, history, time.Now()) + chart := buildChartJSON(history) + + render(w, r, templates.ItemResults(templates.ItemResultsData{ + Page: a.page(r, it.Name, "items"), + Item: *it, + Badge: badge, + History: history, + Results: results, + Page_: page, + TotalPages: totalPages, + Order: order, + HistoryChartJSON: chart, + })) +} + +func buildChartJSON(history []models.PricePoint) string { + c := templates.ChartJSON{ + Labels: make([]string, 0, len(history)), + Points: make([]float64, 0, len(history)), + } + for _, p := range history { + c.Labels = append(c.Labels, p.PolledAt.Format("2006-01-02")) + c.Points = append(c.Points, p.Price) + } + return templates.MustChartJSON(c) +} + +func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + itemID, _ := strconv.ParseInt(q.Get("item_id"), 10, 64) + from := strings.TrimSpace(q.Get("from")) + to := strings.TrimSpace(q.Get("to")) + + items, err := a.Store.ListItems(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + names := make(map[int64]string, len(items)) + for _, it := range items { + names[it.ID] = it.Name + } + + results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{ + ItemID: itemID, + Limit: 200, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + fromT, _ := time.Parse("2006-01-02", from) + toT, _ := time.Parse("2006-01-02", to) + if !toT.IsZero() { + toT = toT.Add(24 * time.Hour) + } + + rows := make([]templates.ItemResultRow, 0, len(results)) + for _, res := range results { + if !fromT.IsZero() && res.FoundAt.Before(fromT) { + continue + } + if !toT.IsZero() && !res.FoundAt.Before(toT) { + continue + } + rows = append(rows, templates.ItemResultRow{ + Result: res, + ItemName: names[res.ItemID], + }) + } + + render(w, r, templates.GlobalResults(templates.GlobalResultsData{ + Page: a.page(r, "Results", "results"), + Items: items, + Results: rows, + ItemID: itemID, + From: from, + To: to, + })) +} diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go new file mode 100644 index 0000000..6f36008 --- /dev/null +++ b/internal/handlers/settings.go @@ -0,0 +1,195 @@ +package handlers + +import ( + "fmt" + "net/http" + "strings" + + "veola/internal/apify" + "veola/internal/auth" + "veola/internal/models" + "veola/internal/ntfy" + "veola/templates" +) + +var settingsKeys = []string{ + "apify_api_key", + "ntfy_base_url", + "ntfy_default_topic", + "ntfy_token", + "global_poll_interval_minutes", + "match_confidence_threshold", +} + +func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) { + values, err := a.Store.GetAllSettings(r.Context()) + if err != nil { + return templates.SettingsData{}, err + } + if values == nil { + values = map[string]string{} + } + users, _ := a.Store.ListUsers(r.Context()) + cur := auth.CurrentUserFromRequest(r) + return templates.SettingsData{ + Page: a.page(r, "Settings", "settings"), + Values: values, + IsAdmin: cur != nil && cur.Role == models.RoleAdmin, + Users: users, + }, nil +} + +func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) { + d, err := a.settingsData(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + render(w, r, templates.Settings(d)) +} + +func (a *App) PostSettings(w http.ResponseWriter, r *http.Request) { + cur := auth.CurrentUserFromRequest(r) + if cur == nil || cur.Role != models.RoleAdmin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + for _, k := range settingsKeys { + v := strings.TrimSpace(r.PostFormValue(k)) + if err := a.Store.SetSetting(r.Context(), k, v); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + http.Redirect(w, r, "/settings", http.StatusSeeOther) +} + +func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) { + cur := auth.CurrentUserFromRequest(r) + if cur == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + current := r.PostFormValue("current_password") + next := r.PostFormValue("new_password") + confirm := r.PostFormValue("new_password_confirm") + + d, err := a.settingsData(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + switch { + case !auth.CheckPassword(cur.PasswordHash, current): + d.PasswordError = "Current password is incorrect" + case len(next) < auth.MinPasswordLen: + d.PasswordError = fmt.Sprintf("New password must be at least %d characters", auth.MinPasswordLen) + case next != confirm: + d.PasswordError = "New passwords do not match" + } + if d.PasswordError != "" { + render(w, r, templates.Settings(d)) + return + } + + hash, err := auth.HashPassword(next) + if err != nil { + http.Error(w, "hash error", http.StatusInternalServerError) + return + } + if err := a.Store.UpdateUserPassword(r.Context(), cur.ID, hash); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + d.PasswordMsg = "Password updated" + render(w, r, templates.Settings(d)) +} + +func (a *App) PostTestNtfy(w http.ResponseWriter, r *http.Request) { + cur := auth.CurrentUserFromRequest(r) + if cur == nil || cur.Role != models.RoleAdmin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + d, err := a.settingsData(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + baseURL := strings.TrimSpace(d.Values["ntfy_base_url"]) + topic := strings.TrimSpace(d.Values["ntfy_default_topic"]) + token := strings.TrimSpace(d.Values["ntfy_token"]) + if baseURL == "" || topic == "" { + d.TestNtfyOK = "Set ntfy base URL and default topic first." + render(w, r, templates.Settings(d)) + return + } + client := ntfy.NewWithToken(baseURL, token) + if err := client.Send(r.Context(), ntfy.Notification{ + Topic: topic, + Title: "Veola test", + Message: "Test notification from Veola settings.", + Priority: "default", + Tags: []string{"white_check_mark"}, + }); err != nil { + d.TestNtfyOK = "Ntfy test failed: " + err.Error() + } else { + d.TestNtfyOK = fmt.Sprintf("Sent test notification to %s/%s", baseURL, topic) + } + render(w, r, templates.Settings(d)) +} + +func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) { + cur := auth.CurrentUserFromRequest(r) + if cur == nil || cur.Role != models.RoleAdmin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + d, err := a.settingsData(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + apiKey := strings.TrimSpace(d.Values["apify_api_key"]) + actorID := a.Cfg.Apify.Actors.ActiveListings + if apiKey == "" { + apiKey = a.Cfg.Apify.APIKey + } + if apiKey == "" || actorID == "" { + d.TestApifyOK = "Apify API key or active_listings actor is not configured." + render(w, r, templates.Settings(d)) + return + } + client := apify.New(apiKey) + var proxy *apify.ProxyConfiguration + p := a.Cfg.Apify.Proxy + if p.UseApifyProxy { + proxy = &apify.ProxyConfiguration{ + UseApifyProxy: true, + ApifyProxyGroups: p.Groups, + ApifyProxyCountry: p.Country, + } + } + raw, err := client.Run(r.Context(), actorID, apify.ActiveListingInput{ + SearchQueries: []string{"test"}, + MaxProductsPerSearch: 1, + MaxSearchPages: 1, + ListingType: "all", + ProxyConfiguration: proxy, + }) + if err != nil { + d.TestApifyOK = "Apify test failed: " + err.Error() + } else { + d.TestApifyOK = fmt.Sprintf("Apify returned %d item(s).", len(raw)) + } + render(w, r, templates.Settings(d)) +} diff --git a/internal/handlers/users.go b/internal/handlers/users.go new file mode 100644 index 0000000..8e8d37e --- /dev/null +++ b/internal/handlers/users.go @@ -0,0 +1,101 @@ +package handlers + +import ( + "fmt" + "net/http" + "strings" + + "veola/internal/auth" + "veola/internal/models" + "veola/templates" +) + +func (a *App) renderSettingsWithUserMsg(w http.ResponseWriter, r *http.Request, msg, errMsg string) { + d, err := a.settingsData(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + d.UserMsg = msg + d.UserError = errMsg + render(w, r, templates.Settings(d)) +} + +func (a *App) PostCreateUser(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + username := strings.TrimSpace(r.PostFormValue("username")) + password := r.PostFormValue("password") + role := strings.TrimSpace(r.PostFormValue("role")) + if role != string(models.RoleAdmin) { + role = string(models.RoleUser) + } + + switch { + case username == "": + a.renderSettingsWithUserMsg(w, r, "", "Username is required") + return + case len(password) < auth.MinPasswordLen: + a.renderSettingsWithUserMsg(w, r, "", fmt.Sprintf("Password must be at least %d characters", auth.MinPasswordLen)) + return + } + existing, _ := a.Store.GetUserByUsername(r.Context(), username) + if existing != nil { + a.renderSettingsWithUserMsg(w, r, "", "User already exists") + return + } + hash, err := auth.HashPassword(password) + if err != nil { + a.renderSettingsWithUserMsg(w, r, "", "hash error") + return + } + if _, err := a.Store.CreateUser(r.Context(), username, hash, models.Role(role)); err != nil { + a.renderSettingsWithUserMsg(w, r, "", err.Error()) + return + } + a.renderSettingsWithUserMsg(w, r, "Created user "+username, "") +} + +func (a *App) PostDeleteUser(w http.ResponseWriter, r *http.Request) { + id := intParam(r, "id") + cur := auth.CurrentUserFromRequest(r) + if cur != nil && cur.ID == id { + a.renderSettingsWithUserMsg(w, r, "", "You cannot delete your own account") + return + } + if err := a.Store.DeleteUser(r.Context(), id); err != nil { + a.renderSettingsWithUserMsg(w, r, "", err.Error()) + return + } + a.renderSettingsWithUserMsg(w, r, "User removed", "") +} + +func (a *App) PostResetPassword(w http.ResponseWriter, r *http.Request) { + id := intParam(r, "id") + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + next := r.PostFormValue("new_password") + if len(next) < auth.MinPasswordLen { + a.renderSettingsWithUserMsg(w, r, "", fmt.Sprintf("Password must be at least %d characters", auth.MinPasswordLen)) + return + } + u, err := a.Store.GetUserByID(r.Context(), id) + if err != nil || u == nil { + a.renderSettingsWithUserMsg(w, r, "", "User not found") + return + } + hash, err := auth.HashPassword(next) + if err != nil { + a.renderSettingsWithUserMsg(w, r, "", "hash error") + return + } + if err := a.Store.UpdateUserPassword(r.Context(), id, hash); err != nil { + a.renderSettingsWithUserMsg(w, r, "", err.Error()) + return + } + a.renderSettingsWithUserMsg(w, r, "Password reset for "+u.Username, "") +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..02863c2 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,122 @@ +package models + +import ( + "strings" + "time" +) + +type Role string + +const ( + RoleAdmin Role = "admin" + RoleUser Role = "user" +) + +type User struct { + ID int64 + Username string + PasswordHash string + Role Role + CreatedAt time.Time +} + +type Item struct { + ID int64 + Name string + SearchQuery string + URL string + Category string + TargetPrice *float64 + NtfyTopic string + NtfyPriority string + PollIntervalMinutes int + IncludeOutOfStock bool + MinPrice *float64 + ExcludeKeywords string + Marketplaces []string + ListingType string + ActorActive string + ActorSold string + ActorPriceCompare string + UsePriceComparison bool + Active bool + LastPolledAt *time.Time + LastPollError string + BestPrice *float64 + BestPriceStore string + BestPriceURL string + BestPriceImageURL string + BestPriceTitle string + CreatedAt time.Time + UpdatedAt time.Time +} + +type Result struct { + ID int64 + ItemID int64 + Title string + Price *float64 + Currency string + URL string + Source string + ImageURL string + MatchedQuery string + Alerted bool + FoundAt time.Time +} + +// SearchQueries returns the item's alias list. Splits on newline, comma, and +// semicolon; trims; drops blanks; dedupes case-insensitively. Result order is +// the user's input order (first occurrence wins). +func (it *Item) SearchQueries() []string { + return SplitList(it.SearchQuery, 10) +} + +// ExcludeKeywordsList returns the item's exclude-keyword list, normalized the +// same way as SearchQueries. +func (it *Item) ExcludeKeywordsList() []string { + return SplitList(it.ExcludeKeywords, 20) +} + +// SplitList splits a user-entered list on newline, comma, or semicolon, +// trims whitespace, drops empty entries, dedupes case-insensitively, and caps +// the result at max entries (0 = no cap). +func SplitList(s string, max int) []string { + if s == "" { + return nil + } + seen := map[string]bool{} + var out []string + for _, part := range strings.FieldsFunc(s, func(r rune) bool { + return r == '\n' || r == '\r' || r == ',' || r == ';' + }) { + t := strings.TrimSpace(part) + if t == "" { + continue + } + k := strings.ToLower(t) + if seen[k] { + continue + } + seen[k] = true + out = append(out, t) + if max > 0 && len(out) >= max { + break + } + } + return out +} + +type PricePoint struct { + ID int64 + ItemID int64 + Price float64 + Store string + PolledAt time.Time +} + +type Setting struct { + Key string + Value string + UpdatedAt time.Time +} diff --git a/internal/ntfy/client.go b/internal/ntfy/client.go new file mode 100644 index 0000000..3168351 --- /dev/null +++ b/internal/ntfy/client.go @@ -0,0 +1,98 @@ +package ntfy + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" +) + +type Client struct { + BaseURL string + Token string + HTTP *http.Client +} + +func New(baseURL string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTP: &http.Client{Timeout: 15 * time.Second}, + } +} + +// NewWithToken returns a ntfy Client with bearer-token auth set. Use this +// when the ntfy server requires authentication. +func NewWithToken(baseURL, token string) *Client { + c := New(baseURL) + c.Token = strings.TrimSpace(token) + return c +} + +type Notification struct { + Topic string + Title string + Message string + Priority string + Tags []string + Click string +} + +// Send publishes to ntfy using the topic-path + header style +// (POST {base}/{topic} with metadata in HTTP headers and the message as the +// raw body). This is the most broadly compatible ntfy publish method — +// works on every ntfy version including self-hosted, and on any path layout +// the server is mounted under. +func (c *Client) Send(ctx context.Context, n Notification) error { + if c.BaseURL == "" { + return fmt.Errorf("ntfy base_url not configured") + } + if n.Topic == "" { + return fmt.Errorf("ntfy topic required") + } + url := c.BaseURL + "/" + strings.TrimLeft(n.Topic, "/") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(n.Message)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "text/plain; charset=utf-8") + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + if n.Title != "" { + req.Header.Set("Title", n.Title) + } + if n.Priority != "" { + req.Header.Set("Priority", n.Priority) + } + if len(n.Tags) > 0 { + req.Header.Set("Tags", strings.Join(n.Tags, ",")) + } + if n.Click != "" { + req.Header.Set("Click", n.Click) + } + tokenLen := len(c.Token) + tokenPrefix := "" + if tokenLen >= 4 { + tokenPrefix = c.Token[:4] + } + slog.Info("ntfy publish", + "url", url, + "topic", n.Topic, + "auth_header_set", c.Token != "", + "token_prefix", tokenPrefix, + "token_len", tokenLen, + ) + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("ntfy POST: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("ntfy returned %d: %s", resp.StatusCode, string(b)) + } + return nil +} diff --git a/internal/scheduler/alert.go b/internal/scheduler/alert.go new file mode 100644 index 0000000..da3f35b --- /dev/null +++ b/internal/scheduler/alert.go @@ -0,0 +1,84 @@ +package scheduler + +import ( + "strings" + + "veola/internal/apify" +) + +// ShouldAlert returns true if the result should trigger an alert. +// +// - Result must not already be in DB (caller checks that first). +// - If targetPrice is nil, alert on every new result. +// - If targetPrice is non-nil, alert only when price <= targetPrice. +// +// price=0 is treated as "unknown" and never alerts under a target. +func ShouldAlert(targetPrice *float64, price float64) bool { + if targetPrice == nil { + return true + } + if price <= 0 { + return false + } + return price <= *targetPrice +} + +// FilterResults applies match-confidence and out-of-stock filtering. Returns +// a fresh slice; the input is not mutated. +func FilterResults(in []apify.UnifiedResult, minConfidence float64, includeOOS bool) []apify.UnifiedResult { + out := make([]apify.UnifiedResult, 0, len(in)) + for _, r := range in { + if !includeOOS && r.OutOfStock { + continue + } + if r.MatchConfidence != 0 && r.MatchConfidence < minConfidence { + continue + } + if r.URL == "" || r.Price <= 0 { + continue + } + out = append(out, r) + } + return out +} + +// ApplyItemFilters drops results below minPrice (when set) and any whose title +// contains one of excludeKeywords (case-insensitive substring match). Pass nil +// or empty for either to skip that filter. Returns a fresh slice. +func ApplyItemFilters(in []apify.UnifiedResult, minPrice *float64, excludeKeywords []string) []apify.UnifiedResult { + lowered := make([]string, 0, len(excludeKeywords)) + for _, k := range excludeKeywords { + k = strings.ToLower(strings.TrimSpace(k)) + if k != "" { + lowered = append(lowered, k) + } + } + out := make([]apify.UnifiedResult, 0, len(in)) +outer: + for _, r := range in { + if minPrice != nil && r.Price < *minPrice { + continue + } + if len(lowered) > 0 { + title := strings.ToLower(r.Title) + for _, k := range lowered { + if strings.Contains(title, k) { + continue outer + } + } + } + out = append(out, r) + } + return out +} + +// PickBest returns the index of the lowest-priced result, or -1 if none. +func PickBest(rs []apify.UnifiedResult) int { + best := -1 + for i, r := range rs { + if best == -1 || r.Price < rs[best].Price { + best = i + } + } + return best +} diff --git a/internal/scheduler/alert_test.go b/internal/scheduler/alert_test.go new file mode 100644 index 0000000..681334b --- /dev/null +++ b/internal/scheduler/alert_test.go @@ -0,0 +1,107 @@ +package scheduler + +import ( + "testing" + + "veola/internal/apify" +) + +func ptr(f float64) *float64 { return &f } + +func TestShouldAlert(t *testing.T) { + cases := []struct { + name string + target *float64 + price float64 + want bool + }{ + {"no target alerts on any positive price", nil, 12.34, true}, + {"no target alerts even on zero price", nil, 0, true}, + {"price below target", ptr(60), 42, true}, + {"price equal to target", ptr(60), 60, true}, + {"price above target", ptr(60), 70, false}, + {"target set but price unknown", ptr(60), 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := ShouldAlert(c.target, c.price) + if got != c.want { + t.Errorf("got %v want %v", got, c.want) + } + }) + } +} + +func TestFilterResults(t *testing.T) { + in := []apify.UnifiedResult{ + {URL: "a", Price: 10, MatchConfidence: 0.9}, + {URL: "b", Price: 10, MatchConfidence: 0.4}, + {URL: "c", Price: 10, OutOfStock: true}, + {URL: "", Price: 10}, + {URL: "e", Price: 0}, + {URL: "f", Price: 12}, + } + got := FilterResults(in, 0.6, false) + if len(got) != 2 { + t.Fatalf("expected 2 results, got %d", len(got)) + } + if got[0].URL != "a" || got[1].URL != "f" { + t.Errorf("unexpected filter output: %+v", got) + } + got2 := FilterResults(in, 0.6, true) + if len(got2) != 3 { + t.Errorf("expected 3 with OOS, got %d", len(got2)) + } +} + +func TestApplyItemFilters(t *testing.T) { + in := []apify.UnifiedResult{ + {URL: "a", Title: "Sony A7 III body", Price: 1200}, + {URL: "b", Title: "Sony A7 III battery grip", Price: 45}, + {URL: "c", Title: "Sony A7 III lens cap", Price: 12}, + {URL: "d", Title: "Sony A7 III with strap", Price: 1100}, + {URL: "e", Title: "for parts not working", Price: 800}, + } + got := ApplyItemFilters(in, ptr(100), []string{"grip", "lens cap", "for parts"}) + if len(got) != 2 { + t.Fatalf("expected 2 results after filter, got %d: %+v", len(got), got) + } + if got[0].URL != "a" || got[1].URL != "d" { + t.Errorf("unexpected filter output: %+v", got) + } + + // Nil/empty filters are no-ops. + got = ApplyItemFilters(in, nil, nil) + if len(got) != len(in) { + t.Errorf("nil filters dropped rows: got %d want %d", len(got), len(in)) + } +} + +func TestDedupByURL(t *testing.T) { + in := []apify.UnifiedResult{ + {Source: "ebay", URL: "https://a", MatchedQuery: "alpha"}, + {Source: "ebay", URL: "https://b", MatchedQuery: "alpha"}, + {Source: "ebay", URL: "https://a", MatchedQuery: "beta"}, // dup of #0 + {Source: "yahoo-auctions-jp", URL: "https://a"}, // different source, same url -> kept + } + got := DedupByURL(in) + if len(got) != 3 { + t.Fatalf("expected 3 deduped, got %d: %+v", len(got), got) + } + if got[0].MatchedQuery != "alpha" { + t.Errorf("first-occurrence MatchedQuery lost: %+v", got[0]) + } +} + +func TestPickBest(t *testing.T) { + rs := []apify.UnifiedResult{ + {Price: 50}, {Price: 30}, {Price: 90}, {Price: 30}, + } + got := PickBest(rs) + if got != 1 { + t.Errorf("expected index 1, got %d", got) + } + if PickBest(nil) != -1 { + t.Error("expected -1 for empty") + } +} diff --git a/internal/scheduler/badge.go b/internal/scheduler/badge.go new file mode 100644 index 0000000..1b7daa9 --- /dev/null +++ b/internal/scheduler/badge.go @@ -0,0 +1,76 @@ +package scheduler + +import ( + "fmt" + "time" + + "veola/internal/models" + "veola/templates" +) + +// PickBadge returns the highest-priority deal-quality badge that applies to +// an item, or an empty BadgeData if none match. Order: +// 1. All-time low +// 2. X% below 30-day avg (only when at least 10% below) +// 3. X% below target +func PickBadge(it models.Item, history []models.PricePoint, now time.Time) templates.BadgeData { + if it.BestPrice == nil { + return templates.BadgeData{} + } + best := *it.BestPrice + + // 1. All-time low + if isAllTimeLow(best, history) { + return templates.BadgeData{Label: "All-time low", Class: "v-badge-low"} + } + + // 2. X% below 30-day average + if avg, ok := windowedMean(history, now, 30*24*time.Hour); ok && best > 0 && avg > 0 { + pct := (avg - best) / avg * 100 + if pct >= 10 { + return templates.BadgeData{ + Label: fmt.Sprintf("%d%% below 30-day avg", int(pct+0.5)), + Class: "v-badge-avg", + } + } + } + + // 3. X% below target + if it.TargetPrice != nil && *it.TargetPrice > 0 && best < *it.TargetPrice { + pct := (*it.TargetPrice - best) / *it.TargetPrice * 100 + return templates.BadgeData{ + Label: fmt.Sprintf("%d%% below target", int(pct+0.5)), + Class: "v-badge-target", + } + } + + return templates.BadgeData{} +} + +func isAllTimeLow(best float64, history []models.PricePoint) bool { + if len(history) == 0 { + return false + } + for _, p := range history { + if p.Price > 0 && p.Price < best { + return false + } + } + return true +} + +func windowedMean(history []models.PricePoint, now time.Time, window time.Duration) (float64, bool) { + cutoff := now.Add(-window) + sum, n := 0.0, 0 + for _, p := range history { + if p.PolledAt.Before(cutoff) { + continue + } + sum += p.Price + n++ + } + if n == 0 { + return 0, false + } + return sum / float64(n), true +} diff --git a/internal/scheduler/badge_test.go b/internal/scheduler/badge_test.go new file mode 100644 index 0000000..21a5a4e --- /dev/null +++ b/internal/scheduler/badge_test.go @@ -0,0 +1,95 @@ +package scheduler + +import ( + "testing" + "time" + + "veola/internal/models" +) + +func bestItem(best, target float64) models.Item { + bp := best + it := models.Item{BestPrice: &bp} + if target > 0 { + t := target + it.TargetPrice = &t + } + return it +} + +func TestPickBadgeAllTimeLow(t *testing.T) { + now := time.Now() + hist := []models.PricePoint{ + {Price: 100, PolledAt: now.Add(-40 * 24 * time.Hour)}, + {Price: 80, PolledAt: now.Add(-10 * 24 * time.Hour)}, + {Price: 60, PolledAt: now.Add(-1 * 24 * time.Hour)}, + } + it := bestItem(50, 0) + got := PickBadge(it, hist, now) + if got.Label != "All-time low" { + t.Errorf("expected all-time low, got %q", got.Label) + } +} + +func TestPickBadgeBelowAverage(t *testing.T) { + now := time.Now() + hist := []models.PricePoint{ + {Price: 100, PolledAt: now.Add(-25 * 24 * time.Hour)}, + {Price: 100, PolledAt: now.Add(-10 * 24 * time.Hour)}, + {Price: 100, PolledAt: now.Add(-5 * 24 * time.Hour)}, + } + it := bestItem(80, 0) // 20% below 100 avg, not lowest because there's no lower in history but best is below points + // add an older lower point so all-time-low is NOT triggered + hist = append(hist, models.PricePoint{Price: 70, PolledAt: now.Add(-90 * 24 * time.Hour)}) + got := PickBadge(it, hist, now) + if got.Label != "20% below 30-day avg" { + t.Errorf("expected 20%% below 30-day avg, got %q", got.Label) + } +} + +func TestPickBadgeBelowTarget(t *testing.T) { + now := time.Now() + // 30-day window mean equals best (50) so avg badge does not fire. + // An older lower point disables the all-time-low badge. + hist := []models.PricePoint{ + {Price: 50, PolledAt: now.Add(-2 * 24 * time.Hour)}, + {Price: 50, PolledAt: now.Add(-1 * 24 * time.Hour)}, + {Price: 40, PolledAt: now.Add(-90 * 24 * time.Hour)}, + } + it := bestItem(50, 100) // 50% below target + got := PickBadge(it, hist, now) + if got.Label != "50% below target" { + t.Errorf("expected 50%% below target, got %q", got.Label) + } +} + +func TestPickBadgeNone(t *testing.T) { + now := time.Now() + // best matches recent avg, no target, and an older lower point exists - + // no badge should fire. + hist := []models.PricePoint{ + {Price: 50, PolledAt: now.Add(-1 * 24 * time.Hour)}, + {Price: 40, PolledAt: now.Add(-90 * 24 * time.Hour)}, + } + it := bestItem(50, 0) + got := PickBadge(it, hist, now) + if got.Label != "" { + t.Errorf("expected no badge, got %q", got.Label) + } +} + +func TestPickBadgeIgnoresShortAvgGap(t *testing.T) { + now := time.Now() + hist := []models.PricePoint{ + {Price: 100, PolledAt: now.Add(-1 * 24 * time.Hour)}, + {Price: 95, PolledAt: now.Add(-2 * 24 * time.Hour)}, + } + // best 92 is only ~5.6% below avg 97.5 — under the 10% floor + older := models.PricePoint{Price: 80, PolledAt: now.Add(-90 * 24 * time.Hour)} + hist = append(hist, older) + it := bestItem(92, 0) + got := PickBadge(it, hist, now) + if got.Label != "" { + t.Errorf("expected no badge for <10%% gap, got %q", got.Label) + } +} diff --git a/internal/scheduler/json.go b/internal/scheduler/json.go new file mode 100644 index 0000000..4220245 --- /dev/null +++ b/internal/scheduler/json.go @@ -0,0 +1,7 @@ +package scheduler + +import "encoding/json" + +func jsonUnmarshal(b []byte, dst any) error { + return json.Unmarshal(b, dst) +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..03c83b2 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,599 @@ +package scheduler + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "github.com/robfig/cron/v3" + + "veola/internal/apify" + "veola/internal/config" + "veola/internal/db" + "veola/internal/models" + "veola/internal/ntfy" +) + +type Scheduler struct { + cfg *config.Config + store *db.Store + apify *apify.Client + ntfy *ntfy.Client + cron *cron.Cron + + mu sync.Mutex + entries map[int64]cron.EntryID + + rootCtx context.Context + cancel context.CancelFunc +} + +func New(cfg *config.Config, store *db.Store, ap *apify.Client, nt *ntfy.Client) *Scheduler { + rootCtx, cancel := context.WithCancel(context.Background()) + return &Scheduler{ + cfg: cfg, + store: store, + apify: ap, + ntfy: nt, + cron: cron.New(), + entries: make(map[int64]cron.EntryID), + rootCtx: rootCtx, + cancel: cancel, + } +} + +func (s *Scheduler) Start(ctx context.Context) error { + items, err := s.store.ListActiveItems(ctx) + if err != nil { + return err + } + for _, it := range items { + s.register(it) + } + s.cron.Start() + slog.Info("scheduler started", "items", len(items)) + return nil +} + +// Stop blocks until running jobs complete. +func (s *Scheduler) Stop() { + s.cancel() + stopCtx := s.cron.Stop() + <-stopCtx.Done() + slog.Info("scheduler stopped") +} + +// SyncItem registers, re-registers, or removes the cron job for an item based +// on its current Active flag. Call after create/update/toggle/delete. +func (s *Scheduler) SyncItem(it models.Item) { + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.entries[it.ID]; ok { + s.cron.Remove(existing) + delete(s.entries, it.ID) + } + if !it.Active { + return + } + s.registerLocked(it) +} + +func (s *Scheduler) RemoveItem(id int64) { + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.entries[id]; ok { + s.cron.Remove(existing) + delete(s.entries, id) + } +} + +func (s *Scheduler) register(it models.Item) { + s.mu.Lock() + defer s.mu.Unlock() + s.registerLocked(it) +} + +func (s *Scheduler) registerLocked(it models.Item) { + mins := it.PollIntervalMinutes + if mins <= 0 { + mins = s.cfg.Scheduler.GlobalPollIntervalMinutes + } + if mins <= 0 { + mins = 60 + } + spec := fmt.Sprintf("@every %dm", mins) + id := it.ID + entryID, err := s.cron.AddFunc(spec, func() { + ctx, cancel := context.WithTimeout(s.rootCtx, 10*time.Minute) + defer cancel() + fresh, err := s.store.GetItem(ctx, id) + if err != nil || fresh == nil || !fresh.Active { + return + } + s.RunPoll(ctx, *fresh) + }) + if err != nil { + slog.Error("schedule failed", "item_id", it.ID, "err", err) + return + } + s.entries[it.ID] = entryID +} + +// RunPoll executes one poll cycle for an item. Public so handlers can trigger +// "Run Now" without going through cron. Iterates over each (alias × marketplace) +// pair; a single failing combo does not poison the others. +func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) { + plans := s.buildAllInputs(it) + if len(plans) == 0 { + s.recordError(ctx, it.ID, "no marketplaces configured for this item") + return + } + apifyClient := s.apifyClient(ctx) + var results []apify.UnifiedResult + var errs []string + successes := 0 + for _, p := range plans { + if p.actorID == "" { + errs = append(errs, fmt.Sprintf("%s: no actor configured", p.marketplace)) + continue + } + raw, err := apifyClient.Run(ctx, p.actorID, p.input) + if err != nil { + label := p.marketplace + if p.query != "" { + label = fmt.Sprintf("query %q on %s", p.query, p.marketplace) + } + errs = append(errs, fmt.Sprintf("%s: %s", label, err.Error())) + slog.Error("apify run failed", "item_id", it.ID, "marketplace", p.marketplace, "query", p.query, "err", err) + continue + } + decoded, _ := apify.Decode(raw, p.source) + usable := 0 + for i := range decoded { + decoded[i].MatchedQuery = p.query + if decoded[i].URL != "" && decoded[i].Price > 0 { + usable++ + } + } + slog.Info("apify run decoded", + "item_id", it.ID, + "marketplace", p.marketplace, + "query", p.query, + "actor", p.actorID, + "raw", len(raw), + "decoded", len(decoded), + "usable", usable, + ) + if usable == 0 && len(raw) > 0 { + var sample map[string]any + if err := jsonUnmarshal(raw[0], &sample); err == nil { + keys := make([]string, 0, len(sample)) + for k := range sample { + keys = append(keys, k) + } + slog.Warn("decoded zero usable rows; raw item keys", + "item_id", it.ID, + "marketplace", p.marketplace, + "actor", p.actorID, + "keys", keys, + ) + } + } + results = append(results, decoded...) + successes++ + } + if successes == 0 { + s.recordError(ctx, it.ID, strings.Join(errs, "; ")) + return + } + + if it.UsePriceComparison { + pcID := it.ActorPriceCompare + if pcID == "" { + pcID = s.cfg.Apify.Actors.PriceComparison + } + if pcID != "" { + pcQueries := it.SearchQueries() + if len(pcQueries) == 0 && it.URL != "" { + pcQueries = []string{""} + } + for _, q := range pcQueries { + pcRaw, err := apifyClient.Run(ctx, pcID, apify.PriceComparisonInput{ + Query: q, URL: it.URL, + ProxyConfiguration: s.proxyConfig(), + }) + if err == nil { + pc, _ := apify.Decode(pcRaw, apify.SourcePriceCompare) + for i := range pc { + pc[i].MatchedQuery = q + } + results = append(results, pc...) + } else { + slog.Warn("price comparison failed", "item_id", it.ID, "query", q, "err", err) + } + } + } + } + + beforeDedup := len(results) + results = DedupByURL(results) + + threshold := s.cfg.Scheduler.MatchConfidenceThreshold + beforeFilter := len(results) + results = FilterResults(results, threshold, it.IncludeOutOfStock) + results = ApplyItemFilters(results, it.MinPrice, it.ExcludeKeywordsList()) + slog.Info("filter applied", + "item_id", it.ID, + "before_dedup", beforeDedup, + "before_filter", beforeFilter, + "after", len(results), + "min_confidence", threshold, + "min_price", it.MinPrice, + "exclude_count", len(it.ExcludeKeywordsList()), + "include_out_of_stock", it.IncludeOutOfStock, + ) + + bestIdx := PickBest(results) + alertsSent := 0 + for _, r := range results { + exists, err := s.store.ResultExists(ctx, it.ID, r.URL) + if err != nil { + slog.Error("dedup check failed", "err", err) + continue + } + if exists { + continue + } + alerted := false + if ShouldAlert(it.TargetPrice, r.Price) { + if err := s.sendNotification(ctx, it, r); err != nil { + slog.Error("ntfy send failed", "err", err) + } else { + alerted = true + alertsSent++ + } + } + price := r.Price + _, err = s.store.InsertResult(ctx, &models.Result{ + ItemID: it.ID, + Title: r.Title, + Price: &price, + Currency: r.Currency, + URL: r.URL, + Source: r.Source, + ImageURL: r.ImageURL, + MatchedQuery: r.MatchedQuery, + Alerted: alerted, + }) + if err != nil { + slog.Error("insert result failed", "err", err) + } + } + + errMsg := "" + if len(errs) > 0 { + errMsg = strings.Join(errs, "; ") + } + if bestIdx >= 0 { + best := results[bestIdx] + bp := best.Price + _ = s.store.UpdateItemPollResult(ctx, it.ID, &models.Item{ + BestPrice: &bp, + BestPriceStore: best.Store, + BestPriceURL: best.URL, + BestPriceImageURL: best.ImageURL, + BestPriceTitle: best.Title, + }, errMsg) + _ = s.store.InsertPricePoint(ctx, &models.PricePoint{ + ItemID: it.ID, + Price: bp, + Store: best.Store, + }) + } else { + _ = s.store.UpdateItemPollResult(ctx, it.ID, nil, errMsg) + } + + slog.Info("poll completed", + "item_id", it.ID, + "item_name", it.Name, + "marketplaces", len(plans), + "successes", successes, + "results", len(results), + "alerts_sent", alertsSent, + ) +} + +func (s *Scheduler) recordError(ctx context.Context, id int64, msg string) { + if err := s.store.UpdateItemPollResult(ctx, id, nil, msg); err != nil { + slog.Error("record error failed", "err", err) + } +} + +// apifyClient returns an apify.Client whose API key reflects the latest +// value from settings, falling back to config.toml. +func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client { + key := s.cfg.Apify.APIKey + if v, _ := s.store.GetSetting(ctx, "apify_api_key"); v != "" { + key = v + } + return apify.New(key) +} + +func (s *Scheduler) sendNotification(ctx context.Context, it models.Item, r apify.UnifiedResult) error { + tags := []string{"mag"} + if it.TargetPrice != nil && r.Price <= *it.TargetPrice { + tags = []string{"shopping_cart", "tada"} + } + priority := it.NtfyPriority + if priority == "" { + priority = "default" + } + topic := it.NtfyTopic + if topic == "" { + if v, _ := s.store.GetSetting(ctx, "ntfy_default_topic"); v != "" { + topic = v + } else { + topic = s.cfg.Ntfy.DefaultTopic + } + } + msg := fmt.Sprintf("%s %s%.2f", r.Store, currencyPrefix(r.Currency), r.Price) + if it.TargetPrice != nil { + msg += fmt.Sprintf(" (target: %s%.2f)", currencyPrefix(r.Currency), *it.TargetPrice) + } + if r.Title != "" { + msg += "\n" + r.Title + } + baseURL := s.cfg.Ntfy.BaseURL + if v, _ := s.store.GetSetting(ctx, "ntfy_base_url"); v != "" { + baseURL = v + } + token, _ := s.store.GetSetting(ctx, "ntfy_token") + client := ntfy.NewWithToken(baseURL, token) + return client.Send(ctx, ntfy.Notification{ + Topic: topic, + Title: fmt.Sprintf("Veola Alert: %s", it.Name), + Message: msg, + Priority: priority, + Tags: tags, + Click: r.URL, + }) +} + +func currencyPrefix(c string) string { + switch c { + case "USD", "": + return "$" + case "GBP": + return "£" + case "EUR": + return "€" + case "JPY": + return "¥" + } + return c + " " +} + +// BuildPreviewInputs returns one actor plan per alias for the first marketplace +// on the item. Preview deliberately uses only one marketplace to limit actor +// runs, but exercises every alias so the operator sees the full result set. +func (s *Scheduler) BuildPreviewInputs(it models.Item) []actorPlan { + queries := it.SearchQueries() + if len(queries) == 0 { + queries = []string{""} + } + markets := it.Marketplaces + if len(markets) > 1 { + markets = markets[:1] + } + var out []actorPlan + for _, q := range queries { + out = append(out, s.buildInputsForQuery(it, q, markets)...) + } + return out +} + +type actorPlan struct { + marketplace string + source string + actorID string + query string + input any +} + +// Marketplace returns the marketplace for this plan. +func (p actorPlan) Marketplace() string { return p.marketplace } + +// Source returns the result-source label (used to pick a decoder). +func (p actorPlan) Source() string { return p.source } + +// ActorID returns the Apify actor ID this plan will invoke. +func (p actorPlan) ActorID() string { return p.actorID } + +// Query returns the alias string this plan searches for. Empty for URL-only items. +func (p actorPlan) Query() string { return p.query } + +// Input returns the actor input payload as expected by apify.Client.Run. +func (p actorPlan) Input() any { return p.input } + +// buildAllInputs returns one actor plan per (alias × marketplace) for the item. +// For URL-only items (no aliases), produces one plan per marketplace with an +// empty query string. +func (s *Scheduler) buildAllInputs(it models.Item) []actorPlan { + queries := it.SearchQueries() + if len(queries) == 0 { + queries = []string{""} + } + markets := it.Marketplaces + if len(markets) == 0 { + markets = []string{"ebay.com"} + } + var out []actorPlan + for _, q := range queries { + out = append(out, s.buildInputsForQuery(it, q, markets)...) + } + return out +} + +// buildInputsForQuery returns one actor plan per marketplace, all using the +// same query string. Used by both the scheduler and the preview path. +func (s *Scheduler) buildInputsForQuery(it models.Item, query string, markets []string) []actorPlan { + url := strings.ToLower(it.URL) + plans := make([]actorPlan, 0, len(markets)) + for _, m := range markets { + mk := strings.ToLower(m) + switch { + case strings.Contains(mk, "yahoo") || strings.Contains(url, "yahoo.co.jp"): + actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.YahooAuctionsJP) + plans = append(plans, actorPlan{m, apify.SourceYahooJP, actorID, query, apify.YahooAuctionsJPInput{ + SearchTerm: query, + MaxPages: 1, + }}) + case strings.Contains(mk, "mercari") || strings.Contains(url, "mercari"): + actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.MercariJP) + plans = append(plans, actorPlan{m, apify.SourceMercariJP, actorID, query, apify.MercariJPInput{ + SearchKeywords: []string{query}, + Status: "on_sale", + MaxResults: 30, + }}) + default: + actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.ActiveListings) + plans = append(plans, actorPlan{m, apify.SourceActiveEbay, actorID, query, apify.ActiveListingInput{ + SearchQueries: []string{query}, + MaxProductsPerSearch: 30, + MaxSearchPages: 1, + Sort: "best_match", + ListingType: mapListingType(it.ListingType), + ProxyConfiguration: s.proxyConfig(), + }}) + } + } + return plans +} + +// DedupByURL collapses duplicates within a single result set. When the same +// listing matches multiple aliases the first occurrence wins, including its +// MatchedQuery tag. +func DedupByURL(in []apify.UnifiedResult) []apify.UnifiedResult { + seen := map[string]bool{} + out := make([]apify.UnifiedResult, 0, len(in)) + for _, r := range in { + if r.URL == "" { + out = append(out, r) + continue + } + key := r.Source + "|" + r.URL + if seen[key] { + continue + } + seen[key] = true + out = append(out, r) + } + return out +} + +// proxyConfig returns the apify proxyConfiguration block built from +// config.toml. Returns nil — meaning omit the field from actor input +// entirely — if use_apify_proxy is false. Group and country are ignored when +// use_apify_proxy is false to prevent contradictory input. +func (s *Scheduler) proxyConfig() *apify.ProxyConfiguration { + p := s.cfg.Apify.Proxy + if !p.UseApifyProxy { + return nil + } + return &apify.ProxyConfiguration{ + UseApifyProxy: true, + ApifyProxyGroups: p.Groups, + ApifyProxyCountry: p.Country, + } +} + +// mapListingType translates Veola's listing-type vocabulary ("all", "BIN", +// "auction") into the automation-lab/ebay-scraper input vocabulary +// ("all", "buy_it_now", "auction"). Unrecognized values fall through as-is +// in case the user pasted a value the actor accepts but we don't. +func mapListingType(s string) string { + switch strings.ToLower(s) { + case "", "all": + return "all" + case "bin", "buy_it_now": + return "buy_it_now" + case "auction": + return "auction" + } + return s +} + +func firstNonEmpty(vs ...string) string { + for _, v := range vs { + if v != "" { + return v + } + } + return "" +} + +// SeedSoldHistory runs the sold-listings actor and writes price_history rows +// for an item just added. Errors are logged and swallowed: a missing baseline +// is not fatal. +func (s *Scheduler) SeedSoldHistory(ctx context.Context, it models.Item) { + queries := it.SearchQueries() + if len(queries) == 0 { + return + } + markets := it.Marketplaces + if len(markets) == 0 { + markets = []string{"ebay.com"} + } + for _, q := range queries { + for _, m := range markets { + s.seedSoldHistoryFor(ctx, it, q, m) + } + } +} + +func (s *Scheduler) seedSoldHistoryFor(ctx context.Context, it models.Item, query, marketplace string) { + actorID := firstNonEmpty(it.ActorSold, s.cfg.Apify.Actors.SoldListings) + source := apify.SourceSoldEbay + if strings.Contains(strings.ToLower(marketplace), "yahoo") { + actorID = firstNonEmpty(it.ActorSold, s.cfg.Apify.Actors.YahooAuctionsJPSold) + source = apify.SourceSoldYahooJP + } + if actorID == "" { + return + } + raw, err := s.apifyClient(ctx).Run(ctx, actorID, apify.SoldListingInput{ + Query: query, Marketplace: marketplace, MaxResults: 50, DaysBack: 30, + ProxyConfiguration: s.proxyConfig(), + }) + if err != nil { + slog.Warn("sold history seed failed", "item_id", it.ID, "marketplace", marketplace, "query", query, "err", err) + return + } + for _, r := range raw { + var sold apify.SoldListingResult + if err := jsonUnmarshal(r, &sold); err != nil || sold.SoldPrice <= 0 { + continue + } + t, _ := time.Parse(time.RFC3339, sold.SoldAt) + if t.IsZero() { + t = time.Now() + } + _ = s.store.InsertPricePoint(ctx, &models.PricePoint{ + ItemID: it.ID, + Price: sold.SoldPrice, + Store: sourceLabelToStore(source), + PolledAt: t, + }) + } +} + +func sourceLabelToStore(src string) string { + switch src { + case apify.SourceSoldYahooJP: + return "yahoo-auctions-jp-sold" + } + return "ebay-sold" +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..961907d --- /dev/null +++ b/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "veola/internal/apify" + "veola/internal/auth" + "veola/internal/config" + "veola/internal/crypto" + "veola/internal/db" + "veola/internal/handlers" + "veola/internal/ntfy" + "veola/internal/scheduler" +) + +func main() { + configPath := flag.String("config", "config.toml", "path to config TOML file") + flag.Parse() + + if err := run(*configPath); err != nil { + slog.Error("fatal", "err", err) + os.Exit(1) + } +} + +func run(configPath string) error { + cfg, err := config.Load(configPath) + if err != nil { + return fmt.Errorf("config: %w", err) + } + + key, err := crypto.DeriveKey([]byte(cfg.Security.EncryptionKey)) + if err != nil { + return fmt.Errorf("derive key: %w", err) + } + + sqlDB, err := db.Open(cfg.Server.DBPath) + if err != nil { + return fmt.Errorf("db open: %w", err) + } + defer sqlDB.Close() + + store := db.NewStore(sqlDB, key) + authMgr, err := auth.NewManager(sqlDB, store, cfg.Security.SessionSecret) + if err != nil { + return fmt.Errorf("auth manager: %w", err) + } + + apifyClient := apify.New(cfg.Apify.APIKey) + ntfyClient := ntfy.New(cfg.Ntfy.BaseURL) + sched := scheduler.New(cfg, store, apifyClient, ntfyClient) + + startCtx, cancelStart := context.WithTimeout(context.Background(), 30*time.Second) + defer cancelStart() + if err := sched.Start(startCtx); err != nil { + return fmt.Errorf("scheduler start: %w", err) + } + + app := handlers.New(cfg, store, authMgr, apifyClient, ntfyClient, sched) + addr := fmt.Sprintf(":%d", cfg.Server.Port) + srv := &http.Server{ + Addr: addr, + Handler: app.Routes(), + ReadHeaderTimeout: 10 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + slog.Info("listening", "addr", addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + close(errCh) + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + select { + case sig := <-sigCh: + slog.Info("shutting down", "signal", sig.String()) + case err := <-errCh: + if err != nil { + return fmt.Errorf("http: %w", err) + } + } + + shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 30*time.Second) + defer cancelShutdown() + if err := srv.Shutdown(shutdownCtx); err != nil { + slog.Error("http shutdown", "err", err) + } + sched.Stop() + slog.Info("shutdown complete") + return nil +} diff --git a/static/css/app.css b/static/css/app.css new file mode 100644 index 0000000..4f9b992 --- /dev/null +++ b/static/css/app.css @@ -0,0 +1,209 @@ +/* Veola — Sega-blue palette and component overrides for Tailwind play CDN. */ + +:root { + --bg: #1a2b6d; + --surface: #1f3380; + --surface-2: #243a93; + --accent: #00a4e4; + --yellow: #f5c400; + --text: #ffffff; + --text-2: #a8c0f0; + --danger: #e84040; + --success: #00e4a4; + --border: rgba(255, 255, 255, 0.12); + --shadow: 0 4px 16px rgba(0, 0, 80, 0.4); +} + +html, body { + background: var(--bg); + color: var(--text); + font-family: 'Outfit', system-ui, sans-serif; +} + +.font-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; } + +a { color: var(--accent); } +a:hover { text-decoration: underline; } + +.v-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.v-card-flat { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; +} + +.v-divider { border-top: 1px solid var(--border); } + +.v-btn { + background: var(--accent); + color: white; + border-radius: 6px; + padding: 0.5rem 1rem; + font-weight: 600; + transition: filter 0.1s ease; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} +.v-btn:hover { filter: brightness(1.1); } +.v-btn[disabled] { opacity: 0.5; cursor: not-allowed; } + +.v-btn-ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 1rem; + cursor: pointer; +} +.v-btn-ghost:hover { background: rgba(255,255,255,0.05); } + +.v-btn-danger { + background: var(--danger); + color: white; + border-radius: 6px; + padding: 0.5rem 1rem; + cursor: pointer; +} + +.v-input, .v-select, .v-textarea { + background: rgba(0, 0, 0, 0.25); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + width: 100%; + font-family: inherit; +} +.v-input:focus, .v-select:focus, .v-textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(0, 164, 228, 0.25); +} + +.v-label { + display: block; + color: var(--text-2); + font-size: 0.85rem; + margin-bottom: 0.3rem; + letter-spacing: 0.01em; +} + +.v-pill { + display: inline-block; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.v-pill-active { background: var(--accent); color: white; } +.v-pill-paused { background: rgba(255,255,255,0.1); color: var(--text-2); } +.v-pill-error { background: var(--danger); color: white; } + +.v-price { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 1.5rem; } +.v-price-target { color: var(--yellow); } +.v-price-deal { color: var(--success); } + +.v-side-nav { + background: var(--surface); + border-right: 1px solid var(--border); + width: 220px; + min-height: 100vh; + position: sticky; + top: 0; +} + +.v-side-nav a { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.7rem 1rem; + color: var(--text-2); + text-decoration: none; + border-left: 3px solid transparent; +} +.v-side-nav a.active { + background: var(--surface-2); + border-left-color: var(--accent); + color: white; +} +.v-side-nav a:hover { color: white; } + +.v-veola-portrait { + background: #f3ead8; + border-radius: 12px; + padding: 1rem; + border: 1px solid var(--border); + box-shadow: var(--shadow); +} +.v-veola-portrait img { display: block; max-width: 100%; height: auto; } + +.v-badge { + display: inline-block; + padding: 0.2rem 0.55rem; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 700; +} +.v-badge-low { background: var(--success); color: #053b2c; } +.v-badge-avg { background: var(--accent); color: white; } +.v-badge-target { background: var(--yellow); color: #2a2200; } + +table.v-table { width: 100%; border-collapse: collapse; } +table.v-table th { + text-align: left; + font-size: 0.75rem; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-2); + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--border); +} +table.v-table td { + padding: 0.7rem 0.75rem; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +table.v-table tr:hover td { background: rgba(255,255,255,0.03); } + +.v-error-text { color: var(--danger); font-size: 0.85rem; } +.v-muted { color: var(--text-2); } + +.v-flash { + background: rgba(0, 164, 228, 0.15); + border: 1px solid var(--accent); + border-radius: 6px; + padding: 0.6rem 0.9rem; + margin-bottom: 1rem; +} +.v-flash-error { + background: rgba(232, 64, 64, 0.15); + border: 1px solid var(--danger); + border-radius: 6px; + padding: 0.6rem 0.9rem; + margin-bottom: 1rem; +} + +/* htmx indicator: hidden by default, visible during in-flight requests. */ +.htmx-indicator { display: none; } +.htmx-request .htmx-indicator, +.htmx-request.htmx-indicator { display: inline-flex; } + +.v-spinner { + display: inline-block; + width: 14px; height: 14px; + border: 2px solid rgba(255,255,255,0.2); + border-top-color: var(--accent); + border-radius: 50%; + animation: v-spin 0.8s linear infinite; +} +@keyframes v-spin { to { transform: rotate(360deg); } } diff --git a/static/img/veola.webp b/static/img/veola.webp new file mode 100644 index 0000000000000000000000000000000000000000..4bae7683af2faf53b7b05bf48f8ced6f20de88c9 GIT binary patch literal 57936 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 YHrgtZU{T-hsVz8rFU_y-YhC~V0GAR$3;+NC literal 0 HcmV?d00001 diff --git a/static/vendor/chart.umd.min.js b/static/vendor/chart.umd.min.js new file mode 100644 index 0000000..4c59ad2 --- /dev/null +++ b/static/vendor/chart.umd.min.js @@ -0,0 +1,20 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/chart.js@4.4.6/dist/chart.umd.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/*! + * Chart.js v4.4.6 + * https://www.chartjs.org + * (c) 2024 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const s=e.length;let n=0,o=s;if(t._sorted){const{iScale:a,_parsed:r}=t,l=a.axis,{min:h,max:c,minDefined:d,maxDefined:u}=a.getUserBounds();d&&(n=J(Math.min(it(r,l,h).lo,i?s:it(e,l,a.getPixelForValue(h)).lo),0,s-1)),o=u?J(Math.max(it(r,a.axis,c,!0).hi+1,i?0:it(e,l,a.getPixelForValue(c),!0).hi+1),n,s)-n:s-n}return{start:n,count:o}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class bt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var xt=new bt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Zt{constructor(t){if(t instanceof Zt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Zt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Jt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Jt(t)?t:new Zt(t)}function te(t){return Jt(t)?t:new Zt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function be(t,e){return me(t).getPropertyValue(e)}const xe=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=xe[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=t&&ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};fe()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch(t){}return t}();function Pe(t,e){const i=be(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Je(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Je(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Je(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e)||"constructor"===e)return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Ze(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Je(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Ze(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const bi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,xi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(bi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(xi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:Z,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hx||l(n,b,p)&&0!==r(n,b),v=()=>!x||0===r(o,p)||l(o,b,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==b&&(x=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,b=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r{t[a]&&t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Xi={evaluateInteractionItems:Hi,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tji(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Yi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>Ui(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ui(t,ve(e,t),"y",i.intersect,s)}};const qi=["left","top","right","bottom"];function Ki(t,e){return t.filter((t=>t.pos===e))}function Gi(t,e){return t.filter((t=>-1===qi.indexOf(t.pos)&&t.box.axis===e))}function Zi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function Ji(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!qi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function ss(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Zi(Ki(e,"left"),!0),n=Zi(Ki(e,"right")),o=Zi(Ki(e,"top"),!0),a=Zi(Ki(e,"bottom")),r=Gi(e,"x"),l=Gi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ki(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);ts(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=Ji(l.concat(h),d);ss(r.fullSize,g,d,p),ss(l,g,d,p),ss(h,g,d,p)&&ss(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),os(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,os(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class rs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class ls extends rs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const hs="$chartjs",cs={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},ds=t=>null===t||""===t;const us=!!Se&&{passive:!0};function fs(t,e,i){t&&t.canvas&&t.canvas.removeEventListener(e,i,us)}function gs(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function ps(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.addedNodes,s),e=e&&!gs(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function ms(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.removedNodes,s),e=e&&!gs(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const bs=new Map;let xs=0;function _s(){const t=window.devicePixelRatio;t!==xs&&(xs=t,bs.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function ys(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){bs.size||window.addEventListener("resize",_s),bs.set(t,e)}(t,o),a}function vs(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){bs.delete(t),bs.size||window.removeEventListener("resize",_s)}(t)}function Ms(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=cs[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t&&t.addEventListener(e,i,us)}(s,e,n),n}class ws extends rs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[hs]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",ds(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(ds(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[hs])return!1;const i=e[hs].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[hs],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:ps,detach:ms,resize:ys}[e]||Ms;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:vs,detach:vs,resize:vs}[e]||fs)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=t&&ge(t);return!(!e||!e.isConnected)}}function ks(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?ls:ws}var Ss=Object.freeze({__proto__:null,BasePlatform:rs,BasicPlatform:ls,DomPlatform:ws,_detectPlatform:ks});const Ps="transparent",Ds={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Ps),n=s.valid&&Qt(e||Ps);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class Cs{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Ds[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new Cs(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(xt.add(this._chart,i),!0):void 0}}function As(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Ts(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function zs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Vs(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Bs=t=>"reset"===t||"none"===t,Ws=(t,e)=>e?t:Object.assign({},t);class Ns{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Es(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Vs(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Fs(t,"x")),o=e.yAxisID=l(i.yAxisID,Fs(t,"y")),a=e.rAxisID=l(i.rAxisID,Fs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Vs(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e)){const t=this._cachedMeta;this._data=function(t,e){const{iScale:i,vScale:s}=e,n="x"===i.axis?"x":"y",o="x"===s.axis?"x":"y",a=Object.keys(t),r=new Array(a.length);let l,h,c;for(l=0,h=a.length;l0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Ts(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Ws(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Os(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Bs(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Bs(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Bs(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function js(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for($s(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,Us=(t,e)=>Math.min(e||t,t);function Xs(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Ks(t){return t.drawTicks?t.tickLength:0}function Gs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Zs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class Js extends Hs{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=J(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Ks(t.grid)-e.padding-Gs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(J((h.highest.height+6)/o,-1,1)),Math.asin(J(a/r,-1,1))-Math.asin(J(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Gs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Ks(n)+o):(t.height=this.maxHeight,t.width=Ks(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Ks(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,b=function(t){return Ae(i,t,p)};let x,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)x=b(this.bottom),w=this.bottom-u,S=x-m,D=b(t.top)+m,O=t.bottom;else if("bottom"===a)x=b(this.top),D=t.top,O=b(t.bottom)-m,w=x+m,S=this.top+u;else if("left"===a)x=b(this.right),M=this.right-u,k=x-m,P=b(t.left)+m,C=t.right;else if("right"===a)x=b(this.left),P=t.left,C=b(t.right)-m,M=x+m,k=this.left+u;else if("x"===e){if("center"===a)x=b((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=x+m,S=w+u}else if("y"===e){if("center"===a)x=b((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}M=x-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_0&&(o-=s/2)}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}b.push({label:v,font:P,textOffset:O,options:{rotation:m,color:i,strokeColor:o,strokeWidth:h,textAlign:f,textBaseline:A,translation:[M,w],backdrop:d}})}return b}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-$(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class tn{constructor(){this.controllers=new Qs(Ns,"datasets",!0),this.elements=new Qs(Hs,"elements"),this.plugins=new Qs(Object,"plugins"),this.scales=new Qs(Js,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function nn(t,e){return e||!1!==t?!0===t?{}:t:null}function on(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function an(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function rn(t){if("x"===t||"y"===t||"r"===t)return t}function ln(t,...e){if(rn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&rn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function hn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function cn(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=an(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=ln(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return hn(t,"x",i[0])||hn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=x(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||an(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),x(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];x(e,[ue.scales[e.type],ue.scale])})),a}function dn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=cn(t,e)}function un(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const fn=new Map,gn=new Set;function pn(t,e){let i=fn.get(t);return i||(i=e(),fn.set(t,i),gn.add(i)),i}const mn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class bn{constructor(t){this._config=function(t){return(t=t||{}).data=un(t.data),dn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=un(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),dn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return pn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return pn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return pn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return pn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>mn(r,t,e)))),e.forEach((t=>mn(r,s,t))),e.forEach((t=>mn(r,re[n]||{},t))),e.forEach((t=>mn(r,ue,t))),e.forEach((t=>mn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),gn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=xn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||_n(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=xn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function xn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const _n=t=>o(t)&&Object.getOwnPropertyNames(t).some((e=>S(t[e])));const yn=["top","bottom","left","right","chartArea"];function vn(t,e){return"top"===t||"bottom"===t||-1===yn.indexOf(t)&&"x"===e}function Mn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function wn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function kn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Sn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Pn={},Dn=t=>{const e=Sn(t);return Object.values(Pn).filter((t=>t.canvas===e)).pop()};function Cn(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}function On(t,e,i){return t.options.clip?t[i]:e[i]}class An{static defaults=ue;static instances=Pn;static overrides=re;static registry=en;static version="4.4.6";static getChart=Dn;static register(...t){en.add(...t),Tn()}static unregister(...t){en.remove(...t),Tn()}constructor(t,e){const s=this.config=new bn(e),n=Sn(t),o=Dn(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||ks(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new sn,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Pn[this.id]=this,r&&l?(xt.listen(this,"complete",wn),xt.listen(this,"progress",kn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return en}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return xt.stop(this),this}resize(t,e){xt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=ln(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=ln(o,n),r=l(n.type,e.dtype);void 0!==n.position&&vn(n.position,a)===vn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(en.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{as.configure(this,t,t.options),as.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(Mn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{as.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){Cn(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;as.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i=t._clip,s=!i.disabled,n=function(t,e){const{xScale:i,yScale:s}=t;return i&&s?{left:On(i,e,"left"),right:On(i,e,"right"),top:On(s,e,"top"),bottom:On(s,e,"bottom")}:e}(t,this.chartArea),o={meta:t,index:t.index,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetDraw",o)&&(s&&Ie(e,{left:!1===i.left?0:n.left-i.left,right:!1===i.right?this.width:n.right+i.right,top:!1===i.top?0:n.top-i.top,bottom:!1===i.bottom?this.height:n.bottom+i.bottom}),t.controller.draw(),s&&ze(e),o.cancelable=!1,this.notifyPlugins("afterDatasetDraw",o))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Xi.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),xt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Tn(){return u(An.instances,(t=>t._plugins.invalidate()))}function Ln(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class En{static override(t){Object.assign(En.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return Ln()}parse(){return Ln()}format(){return Ln()}add(){return Ln()}diff(){return Ln()}startOf(){return Ln()}endOf(){return Ln()}}var Rn={_date:En};function In(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Fn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i,color:s}}=t.legend.options;return e.labels.map(((e,n)=>{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nZ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>Z(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),b=g(C,h,d),x=g(C+E,c,u);s=(p-b)/2,n=(m-x)/2,o=-(p+b)/2,a=-(m+x)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),b=(i.width-o)/f,x=(i.height-o)/g,_=Math.max(Math.min(b,x)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Yn=Object.freeze({__proto__:null,BarController:class extends Ns{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Fn(t,e,i,s)}parseArrayData(t,e,i,s){return Fn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=this._cachedMeta.controller.getParsed(e),l=r&&r[i.axis],h=t=>{const e=t._parsed.find((t=>t[i.axis]===l)),n=e&&e[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!h(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(b-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);b=Math.max(Math.min(b,h),o),d=b+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(b))}if(b===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;b+=t,u-=t}return{size:u,base:b,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,l="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=b?g:{};if(i=x){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),b||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends jn{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:$n,RadarController:class extends Ns{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>b,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),x||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Un(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return J(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:J(n.innerStart,0,a),innerEnd:J(n.innerEnd,0,a)}}function Xn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function qn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,b=n-p-f,{outerStart:x,outerEnd:_,innerStart:y,innerEnd:v}=Un(e,u,d,b-m),M=d-x,w=d-_,k=m+x/M,S=b-_/w,P=u+y,D=u+v,O=m+y/P,A=b-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=Xn(w,S,a,r);t.arc(e.x,e.y,_,S,b+E)}const i=Xn(D,b,a,r);if(t.lineTo(i.x,i.y),v>0){const e=Xn(D,A,a,r);t.arc(e.x,e.y,v,b+E,A+Math.PI)}const s=(b-v/u+(m+y/u))/2;if(t.arc(a,r,u,b-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=Xn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=Xn(M,m,a,r);if(t.lineTo(n.x,n.y),x>0){const e=Xn(M,k,a,r);t.arc(e.x,e.y,x,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Kn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u}=l,f="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,f?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let g=e.endAngle;if(o){qn(t,e,i,s,g,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,g),o||(qn(t,e,i,s,g,n),t.stroke())}function Gn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Jn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[x(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(b*m+e)/++b):(_(),t.lineTo(e,i),u=s,b=0,f=g=i),p=i}_()}function eo(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?to:Qn}const io="function"==typeof Path2D;function so(t,e,i,s){io&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Gn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=eo(e);for(const r of n)Gn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class no extends Hs{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a),g=Z(n,a,r)&&a!==r,p=f>=O||g,m=tt(o,h+u,c+u);return p&&m}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){qn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function po(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,b=!s(a),x=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!b&&!x)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),b&&x&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=b?a:M,w=x?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(b&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return x&&u&&w!==r?i.length&&V(i[i.length-1].value,r,mo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):x&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class xo extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const _o=t=>Math.floor(z(t)),yo=(t,e)=>Math.pow(10,_o(t)+e);function vo(t){return 1===t/Math.pow(10,_o(t))}function Mo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function wo(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=_o(e);let o=function(t,e){let i=_o(e-t);for(;Mo(t,e,i)>10;)i++;for(;Mo(t,e,i)<10;)i--;return Math.min(i,_o(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:vo(g),significand:u}),s}class ko extends Js{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===yo(this.min,0)?yo(this.min,-1):yo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(yo(i,-1)),o(yo(s,1)))),i<=0&&n(yo(s,-1)),s<=0&&o(yo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=wo({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function So(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Po(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Do(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Oo(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function Ao(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function To(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Lo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(So(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/So(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Do(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));To(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e||0===e&&this.min<0){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash||[]),o.lineDashOffset=n.dashOffset,o.beginPath(),Lo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&this.min>=0&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Ro={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Io=Object.keys(Ro);function zo(t,e){return t-e}function Fo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Vo(t,e,i,s){const n=Io.length;for(let o=Io.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function Wo(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class No extends Js{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new Rn._date(t.adapters.date);s.init(e),x(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Fo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Vo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=Io.length-1;o>=Io.indexOf(i);o--){const i=Io[o];if(Ro[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return Io[i?Io.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=Io.indexOf(t)+1,i=Io.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=J(s,0,o),n=J(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Vo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var jo=Object.freeze({__proto__:null,CategoryScale:class extends Js{static id="category";static defaults={ticks:{callback:po}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:J(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:go(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return po.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:xo,LogarithmicScale:ko,RadialLinearScale:Eo,TimeScale:No,TimeSeriesScale:class extends No{static id="timeseries";static defaults=No.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=Ho(e,this.min),this._tableRange=Ho(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(Ho(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return Ho(this._table,i*this._tableRange+this._minPos,!0)}}});const $o=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Yo=$o.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Uo(t){return $o[t%$o.length]}function Xo(t){return Yo[t%Yo.length]}function qo(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof jn?e=function(t,e){return t.backgroundColor=t.data.map((()=>Uo(e++))),e}(i,e):n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Uo(e),t.backgroundColor=Xo(e),++e}(i,e))}}function Ko(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Go={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n,a=Ko(s)||(r=n)&&(r.borderColor||r.backgroundColor)||o&&Ko(o)||"rgba(0,0,0,0.1)"!==ue.borderColor||"rgba(0,0,0,0.1)"!==ue.backgroundColor;var r;if(!i.forceOverride&&a)return;const l=qo(t);s.forEach(l)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Jo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var Qo={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Jo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=J(it(e,o.axis,a).lo,0,i-1)),s=h?J(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const b=[],x=e+i-1,_=t[e].x,y=t[x].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&b.push({...t[e],x:p}),s!==u&&s!==i&&b.push({...t[s],x:p})}o>0&&i!==u&&b.push(t[i]),b.push(a),h=e,m=0,f=g=l,c=d=u=o}}return b}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Jo(t)}};function ta(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ea(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function ia(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function sa(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ea(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new no({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function na(t){return t&&!1!==t.fill}function oa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function aa(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function ra(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&da(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;na(i)&&da(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;na(s)&&"beforeDatasetDraw"===i.drawTime&&da(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const ba=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class xa extends Hs{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=ba(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=_a(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=ba(o,d),b=this.isHorizontal(),x=this._computeTitleHeight();f=b?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+x,line:0}:{x:this.left+c,y:ft(n,this.top+x+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),b?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+x+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,b?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),b)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=_a(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class va extends Hs{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var Ma={id:"title",_element:va,start(t,e,i){!function(t,e){const i=new va({ctx:t.ctx,options:e,chart:t});as.configure(t,i,e),as.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;as.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const wa=new WeakMap;var ka={id:"subtitle",start(t,e,i){const s=new va({ctx:t.ctx,options:i,chart:t});as.configure(t,s,i),as.addBox(t,s),wa.set(t,s)},stop(t){as.removeBox(t,wa.get(t)),wa.delete(t)},beforeUpdate(t,e,i){const s=wa.get(t);as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa={average(t){if(!t.length)return!1;let e,i,s=new Set,n=0,o=0;for(e=0,i=t.length;et+e))/s.size,y:n/o}},nearest(t,e){if(!t.length)return!1;let i,s,n,o=e.x,a=e.y,r=Number.POSITIVE_INFINITY;for(i=0,s=t.length;i-1?t.split("\n"):t}function Ca(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Oa(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,b=0,x=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(x+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),x){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(x-g)*l.lineHeight+(x-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){b=Math.max(b,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),b+=p.width,{width:b,height:m}}function Aa(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ta(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||Aa(t,e,i,s),yAlign:s}}function La(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:J(g,0,s.width-e.width),y:J(p,0,s.height-e.height)}}function Ea(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function Ra(t){return Pa([],Da(t))}function Ia(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const za={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Ia(i,t);Pa(e.before,Da(Fa(n,"beforeLabel",this,t))),Pa(e.lines,Fa(n,"label",this,t)),Pa(e.after,Da(Fa(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return Ra(Fa(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Fa(i,"beforeFooter",this,t),n=Fa(i,"footer",this,t),o=Fa(i,"afterFooter",this,t);let a=[];return a=Pa(a,Da(s)),a=Pa(a,Da(n)),a=Pa(a,Da(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Ia(t.callbacks,e);s.push(Fa(i,"labelColor",this,e)),n.push(Fa(i,"labelPointStyle",this,e)),o.push(Fa(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Sa[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Oa(this,i),a=Object.assign({},t,e),r=Ta(this.chart,i,a),l=La(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,b,x,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,x=_+o,y=_-o):(p=d+f,m=p+o,x=_-o,y=_+o),b=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(x=u,_=x-o,p=m-o,b=m+o):(x=u+g,_=x+o,p=m+o,b=m-o),y=x),{x1:p,x2:m,x3:b,y1:x,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ea(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let b,x,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ea(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Sa[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Oa(this,t),a=Object.assign({},i,this._size),r=Ta(e,t,a),l=La(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e.filter((t=>this.chart.data.datasets[t.datasetIndex]&&void 0!==this.chart.getDatasetMeta(t.datasetIndex).controller.getParsed(t.index)));const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Sa[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Ba={id:"tooltip",_element:Va,positioners:Sa,afterInit(t,e,i){i&&(t.tooltip=new Va({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:za},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return An.register(Yn,jo,fo,t),An.helpers={...Wi},An._adapters=Rn,An.Animation=Cs,An.Animations=Os,An.animator=xt,An.controllers=en.controllers.items,An.DatasetController=Ns,An.Element=Hs,An.elements=fo,An.Interaction=Xi,An.layouts=as,An.platforms=Ss,An.Scale=Js,An.Ticks=ae,Object.assign(An,Yn,jo,fo,t,Ss),An.Chart=An,"undefined"!=typeof window&&(window.Chart=An),An})); +//# sourceMappingURL=chart.umd.js.map diff --git a/static/vendor/htmx.min.js b/static/vendor/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/static/vendor/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/templates/dashboard.templ b/templates/dashboard.templ new file mode 100644 index 0000000..4bde25e --- /dev/null +++ b/templates/dashboard.templ @@ -0,0 +1,165 @@ +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 +} + +templ dashboardBody(d DashboardData) { +
+

Dashboard

+
+ @statCard("Total Items", fmt.Sprintf("%d", d.Stats.TotalItems), "") + @statCard("Active", fmt.Sprintf("%d", d.Stats.ActiveItems), "") + @statCard("Results Today", fmt.Sprintf("%d", d.Stats.ResultsToday), "") + @statCard("Alerts Today", fmt.Sprintf("%d", d.Stats.AlertsToday), "") +
+
+
+
Potential Spend
+
{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }
+
across { fmt.Sprintf("%d", d.Stats.PricedItemCount) } items
+ if d.Stats.UnpricedCount > 0 { +
{ fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount) }
+ } +
+
+
Money Saved
+
{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }
+
across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items
+
+
+
+
+

Recent Results

+ if len(d.RecentResults) == 0 { +
No results yet.
+ } else { + + + + + + for _, r := range d.RecentResults { + + + + + + + } + +
ItemPriceSourceFound
{ r.ItemName }{ fmtPrice(r.Price, r.Currency) }{ r.Source }{ humanTime(r.FoundAt) }
+ } +
+
+

Recent Alerts

+ if len(d.RecentAlerts) == 0 { +
No alerts sent yet.
+ } else { +
    + for _, a := range d.RecentAlerts { +
  • + { a.ItemName } + { fmtPrice(a.Price, a.Currency) } +
  • + } +
+ } +
+
+
+} + +templ statCard(label, value, sub string) { +
+
{ label }
+
{ value }
+ if sub != "" { +
{ sub }
+ } +
+} + +templ Dashboard(d DashboardData) { + @Layout(d.Page, dashboardBody(d)) +} + +// Helpers used by multiple templates. + +func fmtPrice(p *float64, currency string) string { + if p == nil { + return "—" + } + sym := currencySymbol(currency) + return fmt.Sprintf("%s%.2f", sym, *p) +} + +func currencySymbol(c string) string { + switch c { + case "USD", "": + return "$" + case "GBP": + return "£" + case "EUR": + return "€" + case "JPY": + return "¥" + default: + return c + " " + } +} + +func humanTime(t time.Time) string { + if t.IsZero() { + return "—" + } + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + m := int(d.Minutes()) + return fmt.Sprintf("%d minutes ago", m) + case d < 24*time.Hour: + h := int(d.Hours()) + return fmt.Sprintf("%d hours ago", h) + case d < 30*24*time.Hour: + d2 := int(d.Hours() / 24) + return fmt.Sprintf("%d days ago", d2) + default: + return t.Format("2006-01-02") + } +} + +// Used by item rendering. +var _ = models.Item{} diff --git a/templates/dashboard_templ.go b/templates/dashboard_templ.go new file mode 100644 index 0000000..39670fd --- /dev/null +++ b/templates/dashboard_templ.go @@ -0,0 +1,467 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +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 +} + +func dashboardBody(d DashboardData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Dashboard

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statCard("Total Items", fmt.Sprintf("%d", d.Stats.TotalItems), "").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statCard("Active", fmt.Sprintf("%d", d.Stats.ActiveItems), "").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statCard("Results Today", fmt.Sprintf("%d", d.Stats.ResultsToday), "").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statCard("Alerts Today", fmt.Sprintf("%d", d.Stats.AlertsToday), "").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Potential Spend
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 49, Col: 87} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
across ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.PricedItemCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 50, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " items
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Stats.UnpricedCount > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 52, Col: 103} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
Money Saved
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 57, Col: 96} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
across ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.SavedItemCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 58, Col: 88} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " items

Recent Results

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(d.RecentResults) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
No results yet.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, r := range d.RecentResults { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
ItemPriceSourceFound
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 74, Col: 95} + } + _, 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, 14, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 75, Col: 62} + } + _, 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, 15, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 77, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

Recent Alerts

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(d.RecentAlerts) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
No alerts sent yet.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, a := range d.RecentAlerts { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(a.ItemName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 92, Col: 26} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(a.Price, a.Currency)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 93, Col: 78} + } + _, 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, 24, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func statCard(label, value, sub string) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var14 := templ.GetChildren(ctx) + if templ_7745c5c3_Var14 == nil { + templ_7745c5c3_Var14 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 105, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(value) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 106, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if sub != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sub) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 42} + } + _, 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, 31, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func Dashboard(d DashboardData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var18 := templ.GetChildren(ctx) + if templ_7745c5c3_Var18 == nil { + templ_7745c5c3_Var18 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = Layout(d.Page, dashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// 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{} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/item_form.templ b/templates/item_form.templ new file mode 100644 index 0000000..5c7d1e1 --- /dev/null +++ b/templates/item_form.templ @@ -0,0 +1,302 @@ +package templates + +import ( + "fmt" + + "veola/internal/models" +) + +type ItemFormData struct { + Page + IsEdit bool + Item models.Item + Categories []string + Errors []string +} + +func itemSelected(have, want string) bool { return have == want } + +type marketplaceOpt struct { + Value string + Label string +} + +func marketplaceOptions() []marketplaceOpt { + return []marketplaceOpt{ + {"ebay.com", "eBay (US)"}, + {"ebay.co.uk", "eBay (UK)"}, + {"ebay.de", "eBay (DE)"}, + {"ebay.fr", "eBay (FR)"}, + {"ebay.com.au", "eBay (AU)"}, + {"ebay.ca", "eBay (CA)"}, + {"yahoo.co.jp", "Yahoo Auctions JP"}, + {"mercari.jp", "Mercari JP"}, + } +} + +func isKnownMarketplace(v string) bool { + for _, o := range marketplaceOptions() { + if o.Value == v { + return true + } + } + return false +} + +func itemHasMarketplace(it models.Item, v string) bool { + for _, m := range it.Marketplaces { + if m == v { + return true + } + } + return false +} + +// customMarketplacesCSV returns the comma-separated list of marketplaces on +// the item that are NOT in the curated list, so the user can keep editing +// unusual values without losing them. +func customMarketplacesCSV(it models.Item) string { + var custom []string + for _, m := range it.Marketplaces { + if !isKnownMarketplace(m) { + custom = append(custom, m) + } + } + return joinCSV(custom) +} + +func joinCSV(vs []string) string { + out := "" + for i, v := range vs { + if i > 0 { + out += ", " + } + out += v + } + return out +} + +func itemHasJapanMarketplace(it models.Item) bool { + for _, m := range it.Marketplaces { + if m == "yahoo.co.jp" || m == "mercari.jp" { + return true + } + } + return false +} + +func newCategory(v string, known []string) string { + if v == "" { + return "" + } + for _, c := range known { + if c == v { + return "" + } + } + return v +} + +templ itemFormBody(d ItemFormData) { +
+

+ if d.IsEdit { + Edit { d.Item.Name } + } else { + Add Item + } +

+ if len(d.Errors) > 0 { +
+
    + for _, e := range d.Errors { +
  • { e }
  • + } +
+
+ } + @itemFormInner(d) +
+} + +templ itemFormInner(d ItemFormData) { +
+ @CSRFInput(d.CSRFToken) +
+
+ + +
+
+ + + +
+
+
+ + +
Each is searched independently; the lowest price across all wins. Cost scales with the number of queries.
+
+
+ + +
At least one of search queries or URL is required.
+
+
+
+ + +
Blank = alert on every new result.
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+ for _, m := range marketplaceOptions() { + + } +
+ +
Yahoo Auctions JP and Mercari JP search in Japanese. English queries return few or no results.
+
Pick one or more. Each is polled per cycle; one failure does not stop the others.
+
+
+ + +
+
+
+
+ + +
Drop results below this price. Filters out accessories and obvious junk.
+
+
+ + +
Drop results whose title contains any of these. Case-insensitive substring match.
+
+
+ +
+ Advanced +
Leave blank to use the configured default for the selected marketplace.
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ if d.IsEdit { + + Cancel + } else { + + Cancel + + + Running preview… apify runs can take 30–60s. + + } +
+
+ if !d.IsEdit { +
+ } +} + +func formAction(d ItemFormData) templ.SafeURL { + if d.IsEdit { + return templ.SafeURL(fmt.Sprintf("/items/%d", d.Item.ID)) + } + return templ.SafeURL("/items/preview") +} + +type pollOpt struct { + Minutes int + Label string +} + +func pollOptions() []pollOpt { + return []pollOpt{ + {15, "15 min"}, {30, "30 min"}, {60, "1 hr"}, {120, "2 hr"}, + {360, "6 hr"}, {720, "12 hr"}, {1440, "24 hr"}, + } +} + +func optFloat(p *float64) string { + if p == nil { + return "" + } + return fmt.Sprintf("%.2f", *p) +} + +templ ItemForm(d ItemFormData) { + @Layout(d.Page, itemFormBody(d)) +} diff --git a/templates/item_form_templ.go b/templates/item_form_templ.go new file mode 100644 index 0000000..15627cb --- /dev/null +++ b/templates/item_form_templ.go @@ -0,0 +1,760 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + + "veola/internal/models" +) + +type ItemFormData struct { + Page + IsEdit bool + Item models.Item + Categories []string + Errors []string +} + +func itemSelected(have, want string) bool { return have == want } + +type marketplaceOpt struct { + Value string + Label string +} + +func marketplaceOptions() []marketplaceOpt { + return []marketplaceOpt{ + {"ebay.com", "eBay (US)"}, + {"ebay.co.uk", "eBay (UK)"}, + {"ebay.de", "eBay (DE)"}, + {"ebay.fr", "eBay (FR)"}, + {"ebay.com.au", "eBay (AU)"}, + {"ebay.ca", "eBay (CA)"}, + {"yahoo.co.jp", "Yahoo Auctions JP"}, + {"mercari.jp", "Mercari JP"}, + } +} + +func isKnownMarketplace(v string) bool { + for _, o := range marketplaceOptions() { + if o.Value == v { + return true + } + } + return false +} + +func itemHasMarketplace(it models.Item, v string) bool { + for _, m := range it.Marketplaces { + if m == v { + return true + } + } + return false +} + +// customMarketplacesCSV returns the comma-separated list of marketplaces on +// the item that are NOT in the curated list, so the user can keep editing +// unusual values without losing them. +func customMarketplacesCSV(it models.Item) string { + var custom []string + for _, m := range it.Marketplaces { + if !isKnownMarketplace(m) { + custom = append(custom, m) + } + } + return joinCSV(custom) +} + +func joinCSV(vs []string) string { + out := "" + for i, v := range vs { + if i > 0 { + out += ", " + } + out += v + } + return out +} + +func itemHasJapanMarketplace(it models.Item) bool { + for _, m := range it.Marketplaces { + if m == "yahoo.co.jp" || m == "mercari.jp" { + return true + } + } + return false +} + +func newCategory(v string, known []string) string { + if v == "" { + return "" + } + for _, c := range known { + if c == v { + return "" + } + } + return v +} + +func itemFormBody(d ItemFormData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.IsEdit { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "Edit ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 104, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Add Item") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(d.Errors) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, e := range d.Errors { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(e) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 113, Col: 13} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = itemFormInner(d).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func itemFormInner(d ItemFormData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CSRFInput(d.CSRFToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
Each is searched independently; the lowest price across all wins. Cost scales with the number of queries.
At least one of search queries or URL is required.
Blank = alert on every new result.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, m := range marketplaceOptions() { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
Yahoo Auctions JP and Mercari JP search in Japanese. English queries return few or no results.
Pick one or more. Each is polled per cycle; one failure does not stop the others.
Drop results below this price. Filters out accessories and obvious junk.
Drop results whose title contains any of these. Case-insensitive substring match.
Advanced
Leave blank to use the configured default for the selected marketplace.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.IsEdit { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " Cancel") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " Cancel Running preview… apify runs can take 30–60s.") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !d.IsEdit { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func formAction(d ItemFormData) templ.SafeURL { + if d.IsEdit { + return templ.SafeURL(fmt.Sprintf("/items/%d", d.Item.ID)) + } + return templ.SafeURL("/items/preview") +} + +type pollOpt struct { + Minutes int + Label string +} + +func pollOptions() []pollOpt { + return []pollOpt{ + {15, "15 min"}, {30, "30 min"}, {60, "1 hr"}, {120, "2 hr"}, + {360, "6 hr"}, {720, "12 hr"}, {1440, "24 hr"}, + } +} + +func optFloat(p *float64) string { + if p == nil { + return "" + } + return fmt.Sprintf("%.2f", *p) +} + +func ItemForm(d ItemFormData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var28 := templ.GetChildren(ctx) + if templ_7745c5c3_Var28 == nil { + templ_7745c5c3_Var28 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = Layout(d.Page, itemFormBody(d)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/item_preview.templ b/templates/item_preview.templ new file mode 100644 index 0000000..14aa52b --- /dev/null +++ b/templates/item_preview.templ @@ -0,0 +1,163 @@ +package templates + +import ( + "fmt" + + "veola/internal/apify" +) + +type PreviewData struct { + CSRFToken string + Form FormValues + Results []apify.UnifiedResult + BestIndex int + MinPrice float64 + MaxPrice float64 + StoreCount int + Error string + Empty bool + Cached bool + Currency string +} + +// FormValues mirrors the Step 1 form so the confirm POST has every field. +type FormValues struct { + Name string + SearchQuery string + URL string + Category string + TargetPrice string + MinPrice string + ExcludeKeywords string + NtfyTopic string + NtfyPriority string + PollIntervalMinutes string + IncludeOutOfStock bool + Marketplaces []string + ListingType string + ActorActive string + ActorSold string + ActorPriceCompare string + UsePriceComparison bool +} + +templ ItemPreview(d PreviewData) { + if d.Error != "" { +
+
Could not run preview
+
{ d.Error }
+ +
+ } else if d.Empty { +
+
No results found
+
Try a broader search query or a different marketplace.
+
+ } else { +
+
+

+ { fmt.Sprintf("Found %d results for '%s'", len(d.Results), d.Form.SearchQuery) } + if d.Cached { + cached + } +

+
+ @previewBest(d) + if len(d.Results) > 1 { +
+
Other results
+
    + for i, r := range d.Results { + if i != d.BestIndex && i < d.BestIndex+6 { +
  • + if r.ImageURL != "" { + + } +
    + { r.Title } +
    { r.Store }
    +
    +
    { fmtNumber(r.Price, r.Currency) }
    +
  • + } + } +
+ if len(d.Results) > 6 { +
{ fmt.Sprintf("and %d more", len(d.Results)-6) }
+ } +
+ } +
+
+ { fmt.Sprintf("Prices range from %s to %s across %d stores", fmtNumber(d.MinPrice, d.Currency), fmtNumber(d.MaxPrice, d.Currency), d.StoreCount) } +
+
+ } + @confirmForm(d) +} + +templ previewBest(d PreviewData) { + if d.BestIndex >= 0 && d.BestIndex < len(d.Results) { +
+ if d.Results[d.BestIndex].ImageURL != "" { + + } else { +
+ } +
+
Best Price
+
{ fmtNumber(d.Results[d.BestIndex].Price, d.Currency) }
+ { d.Results[d.BestIndex].Title } +
{ d.Results[d.BestIndex].Store }
+ if d.Results[d.BestIndex].MatchedQuery != "" { +
via "{ d.Results[d.BestIndex].MatchedQuery }"
+ } +
+
+ } +} + +templ confirmForm(d PreviewData) { +
+ + @hidden("name", d.Form.Name) + @hidden("search_query", d.Form.SearchQuery) + @hidden("url", d.Form.URL) + @hidden("category", d.Form.Category) + @hidden("target_price", d.Form.TargetPrice) + @hidden("min_price", d.Form.MinPrice) + @hidden("exclude_keywords", d.Form.ExcludeKeywords) + @hidden("ntfy_topic", d.Form.NtfyTopic) + @hidden("ntfy_priority", d.Form.NtfyPriority) + @hidden("poll_interval_minutes", d.Form.PollIntervalMinutes) + @hiddenBool("include_out_of_stock", d.Form.IncludeOutOfStock) + for _, m := range d.Form.Marketplaces { + @hidden("marketplace", m) + } + @hidden("listing_type", d.Form.ListingType) + @hidden("actor_active", d.Form.ActorActive) + @hidden("actor_sold", d.Form.ActorSold) + @hidden("actor_price_compare", d.Form.ActorPriceCompare) + @hiddenBool("use_price_comparison", d.Form.UsePriceComparison) + + Back +
+} + +templ hidden(name, value string) { + +} + +templ hiddenBool(name string, value bool) { + if value { + + } +} + +func fmtNumber(p float64, currency string) string { + if p == 0 { + return "—" + } + return fmt.Sprintf("%s%.2f", currencySymbol(currency), p) +} diff --git a/templates/item_preview_templ.go b/templates/item_preview_templ.go new file mode 100644 index 0000000..063066f --- /dev/null +++ b/templates/item_preview_templ.go @@ -0,0 +1,636 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + + "veola/internal/apify" +) + +type PreviewData struct { + CSRFToken string + Form FormValues + Results []apify.UnifiedResult + BestIndex int + MinPrice float64 + MaxPrice float64 + StoreCount int + Error string + Empty bool + Cached bool + Currency string +} + +// FormValues mirrors the Step 1 form so the confirm POST has every field. +type FormValues struct { + Name string + SearchQuery string + URL string + Category string + TargetPrice string + MinPrice string + ExcludeKeywords string + NtfyTopic string + NtfyPriority string + PollIntervalMinutes string + IncludeOutOfStock bool + Marketplaces []string + ListingType string + ActorActive string + ActorSold string + ActorPriceCompare string + UsePriceComparison bool +} + +func ItemPreview(d PreviewData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if d.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
Could not run preview
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 48, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if d.Empty { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
No results found
Try a broader search query or a different marketplace.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Found %d results for '%s'", len(d.Results), d.Form.SearchQuery)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 60, Col: 83} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Cached { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "cached") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = previewBest(d).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(d.Results) > 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
Other results
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(d.Results) > 6 { + 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(fmt.Sprintf("and %d more", len(d.Results)-6)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 87, Col: 86} + } + _, 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Prices range from %s to %s across %d stores", fmtNumber(d.MinPrice, d.Currency), fmtNumber(d.MaxPrice, d.Currency), d.StoreCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 93, Col: 148} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = confirmForm(d).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func previewBest(d PreviewData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if d.BestIndex >= 0 && d.BestIndex < len(d.Results) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Results[d.BestIndex].ImageURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + 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, 27, "
Best Price
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(d.Results[d.BestIndex].Price, d.Currency)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 110, Col: 94} + } + _, 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, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 111, Col: 141} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + 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 + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Store) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 112, Col: 68} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Results[d.BestIndex].MatchedQuery != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
via \"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].MatchedQuery) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 114, Col: 81} + } + _, 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, 33, "\"
") + 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 + } + } + return nil + }) +} + +func confirmForm(d PreviewData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var18 := templ.GetChildren(ctx) + if templ_7745c5c3_Var18 == nil { + templ_7745c5c3_Var18 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("name", d.Form.Name).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("search_query", d.Form.SearchQuery).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("url", d.Form.URL).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("category", d.Form.Category).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("target_price", d.Form.TargetPrice).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("min_price", d.Form.MinPrice).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("exclude_keywords", d.Form.ExcludeKeywords).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("ntfy_topic", d.Form.NtfyTopic).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("ntfy_priority", d.Form.NtfyPriority).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("poll_interval_minutes", d.Form.PollIntervalMinutes).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hiddenBool("include_out_of_stock", d.Form.IncludeOutOfStock).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, m := range d.Form.Marketplaces { + templ_7745c5c3_Err = hidden("marketplace", m).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = hidden("listing_type", d.Form.ListingType).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("actor_active", d.Form.ActorActive).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("actor_sold", d.Form.ActorSold).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("actor_price_compare", d.Form.ActorPriceCompare).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hiddenBool("use_price_comparison", d.Form.UsePriceComparison).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " Back
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func hidden(name, value string) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var20 := templ.GetChildren(ctx) + if templ_7745c5c3_Var20 == nil { + templ_7745c5c3_Var20 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func hiddenBool(name string, value bool) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var23 := templ.GetChildren(ctx) + if templ_7745c5c3_Var23 == nil { + templ_7745c5c3_Var23 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if value { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func fmtNumber(p float64, currency string) string { + if p == 0 { + return "—" + } + return fmt.Sprintf("%s%.2f", currencySymbol(currency), p) +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/items.templ b/templates/items.templ new file mode 100644 index 0000000..8772170 --- /dev/null +++ b/templates/items.templ @@ -0,0 +1,162 @@ +package templates + +import ( + "fmt" + + "veola/internal/models" +) + +type ItemsData struct { + Page + Items []models.Item + Categories []string + SelectedCategory string +} + +templ itemsBody(d ItemsData) { +
+
+

Items

+ + Add Item +
+ if len(d.Categories) > 0 { +
+ + +
+ } + if len(d.Items) == 0 { + @itemsEmpty() + } else { +
+ + + + + + + + + + + + + + for _, it := range d.Items { + @itemRow(it, d.CSRFToken) + } + +
NameCategoryTargetBest PriceLast PolledStatus
+
+ } +
+} + +templ itemsEmpty() { +
+
+ Veola +
+
+

Nothing on the watchlist.

+

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

+ Add the first item +
+
+} + +templ itemRow(it models.Item, csrf string) { + + + { it.Name } + if it.LastPollError != "" { + +
+ } + + { it.Category } + + if it.TargetPrice != nil { + { fmtPrice(it.TargetPrice, "USD") } + } else { + + } + + + if it.BestPrice != nil { +
{ fmtPrice(it.BestPrice, "USD") }
+ if it.BestPriceURL != "" { + { it.BestPriceStore } + } else if it.BestPriceStore != "" { + { it.BestPriceStore } + } + } else { + not yet + } + + + if it.LastPolledAt != nil { + { humanTime(*it.LastPolledAt) } + } else { + — + } + + + if it.Active { + active + } else { + paused + } + + +
+ + +
+
+ + +
+ Edit +
+ + +
+ + +} + +func priceClass(best, target *float64) string { + if best == nil || target == nil { + return "" + } + if *best <= *target { + return "v-price-target" + } + return "" +} + +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) +} + +// EmptyRow lets a delete handler return a row replacement that vanishes. +templ EmptyRow() { + +} diff --git a/templates/items_templ.go b/templates/items_templ.go new file mode 100644 index 0000000..dec3974 --- /dev/null +++ b/templates/items_templ.go @@ -0,0 +1,705 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + + "veola/internal/models" +) + +type ItemsData struct { + Page + Items []models.Item + Categories []string + SelectedCategory string +} + +func itemsBody(d ItemsData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(d.Categories) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(d.Items) == 0 { + templ_7745c5c3_Err = itemsEmpty().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + 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) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
NameCategoryTargetBest PriceLast PolledStatus
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func itemsEmpty() 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + 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
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func itemRow(it models.Item, csrf string) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + 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} + } + _, 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, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if it.LastPollError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, 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} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + 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")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 85, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + 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...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.BestPrice, "USD")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 92, Col: 112} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") + 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 + } + 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 96, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "not yet") + 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 + } + if it.LastPolledAt != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") + 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)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 104, Col: 95} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "—") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if it.Active { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "active") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "paused") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
Edit
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func priceClass(best, target *float64) string { + if best == nil || target == nil { + return "" + } + if *best <= *target { + return "v-price-target" + } + return "" +} + +func Items(d ItemsData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var32 := templ.GetChildren(ctx) + if templ_7745c5c3_Var32 == nil { + templ_7745c5c3_Var32 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = Layout(d.Page, itemsBody(d)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// ItemRow renders a single row partial, used by HTMX endpoints. +func ItemRow(it models.Item, csrf string) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var33 := templ.GetChildren(ctx) + if templ_7745c5c3_Var33 == nil { + templ_7745c5c3_Var33 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = itemRow(it, csrf).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// EmptyRow lets a delete handler return a row replacement that vanishes. +func EmptyRow() 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var34 := templ.GetChildren(ctx) + if templ_7745c5c3_Var34 == nil { + templ_7745c5c3_Var34 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/layout.templ b/templates/layout.templ new file mode 100644 index 0000000..a3923a0 --- /dev/null +++ b/templates/layout.templ @@ -0,0 +1,86 @@ +package templates + +import "veola/internal/models" + +// Page is the data carrier for any rendered page; concrete handlers populate +// only the fields they use. +type Page struct { + Title string + Active string + CSRFToken string + CurrentUser *models.User + Flash string + FlashError string +} + +templ head(title string) { + + + + { title } · Veola + + + + + + + +} + +templ Sidebar(active string) { + +} + +func navClass(key, active string) string { + if key == active { + return "active" + } + return "" +} + +templ Layout(p Page, body templ.Component) { + + + @head(p.Title) + + @Sidebar(p.Active) +
+ if p.Flash != "" { +
{ p.Flash }
+ } + if p.FlashError != "" { +
{ p.FlashError }
+ } + @body +
+ + +} + +// Bare is a chrome-less layout used by /login and /setup. +templ Bare(p Page, body templ.Component) { + + + @head(p.Title) + + @body + + +} + +// CSRFInput is the hidden form field for state-changing forms. +templ CSRFInput(token string) { + +} diff --git a/templates/layout_templ.go b/templates/layout_templ.go new file mode 100644 index 0000000..8c48dcb --- /dev/null +++ b/templates/layout_templ.go @@ -0,0 +1,370 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "veola/internal/models" + +// Page is the data carrier for any rendered page; concrete handlers populate +// only the fields they use. +type Page struct { + Title string + Active string + CSRFToken string + CurrentUser *models.User + Flash string + FlashError string +} + +func head(title string) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 20, Col: 16} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Veola") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func Sidebar(active string) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func navClass(key, active string) string { + if key == active { + return "active" + } + return "" +} + +func Layout(p Page, body templ.Component) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = head(p.Title).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Sidebar(p.Active).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if p.Flash != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(p.Flash) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 61, Col: 35} + } + _, 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, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if p.FlashError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.FlashError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 64, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = body.Render(ctx, templ_7745c5c3_Buffer) + 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 + } + return nil + }) +} + +// Bare is a chrome-less layout used by /login and /setup. +func Bare(p Page, body templ.Component) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var15 := templ.GetChildren(ctx) + if templ_7745c5c3_Var15 == nil { + templ_7745c5c3_Var15 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = head(p.Title).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = body.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// CSRFInput is the hidden form field for state-changing forms. +func CSRFInput(token string) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/login.templ b/templates/login.templ new file mode 100644 index 0000000..2d4a8cd --- /dev/null +++ b/templates/login.templ @@ -0,0 +1,95 @@ +package templates + +type LoginData struct { + Page + Error string + Username string +} + +templ loginBody(d LoginData) { +
+
+
+
+ 🐝 + Veola +
+

Open the door.

+

Sign in to continue.

+ if d.Error != "" { +
{ d.Error }
+ } +
+ @CSRFInput(d.CSRFToken) +
+ + +
+
+ + +
+ +
+
+
+ +
+} + +templ Login(d LoginData) { + @Bare(d.Page, loginBody(d)) +} + +type SetupData struct { + Page + Error string + Username string +} + +templ setupBody(d SetupData) { +
+
+
+
+ 🐝 + Veola +
+

First time here.

+

Create the admin account. Password must be at least 12 characters.

+ if d.Error != "" { +
{ d.Error }
+ } +
+ @CSRFInput(d.CSRFToken) +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+} + +templ Setup(d SetupData) { + @Bare(d.Page, setupBody(d)) +} diff --git a/templates/login_templ.go b/templates/login_templ.go new file mode 100644 index 0000000..53faac0 --- /dev/null +++ b/templates/login_templ.go @@ -0,0 +1,227 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +type LoginData struct { + Page + Error string + Username string +} + +func loginBody(d LoginData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
🐝 Veola

Open the door.

Sign in to continue.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 20, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CSRFInput(d.CSRFToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
\"Veola\"
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func Login(d LoginData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = Bare(d.Page, loginBody(d)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +type SetupData struct { + Page + Error string + Username string +} + +func setupBody(d SetupData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
🐝 Veola

First time here.

Create the admin account. Password must be at least 12 characters.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 65, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CSRFInput(d.CSRFToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
\"Veola\"
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func Setup(d SetupData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = Bare(d.Page, setupBody(d)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/results.templ b/templates/results.templ new file mode 100644 index 0000000..bba6496 --- /dev/null +++ b/templates/results.templ @@ -0,0 +1,266 @@ +package templates + +import ( + "encoding/json" + "fmt" + + "veola/internal/models" +) + +type ItemResultsData struct { + Page + Item models.Item + Badge BadgeData + History []models.PricePoint + Results []models.Result + Page_ int + TotalPages int + Order string + HistoryChartJSON string +} + +type BadgeData struct { + Label string + Class string // v-badge-low / v-badge-avg / v-badge-target / "" +} + +type GlobalResultsData struct { + Page + Items []models.Item + Results []ItemResultRow + ItemID int64 + From string + To string +} + +type ItemResultRow struct { + models.Result + ItemName string +} + +templ itemResultsBody(d ItemResultsData) { +
+
+
+

{ d.Item.Name }

+ if d.Item.Category != "" { +
{ d.Item.Category }
+ } +
+
+ if d.Item.BestPrice != nil { +
{ fmtPrice(d.Item.BestPrice, "USD") }
+ if d.Item.BestPriceURL != "" { + { d.Item.BestPriceStore } + } + } else { +
no price yet
+ } + if d.Badge.Label != "" { +
+ { d.Badge.Label } +
+ } +
+ + +
+
+
+ +
+

Price History

+ if len(d.History) < 2 { +
Not enough history yet.
+ } else { + + + + + } +
+ +
+ + + + + + + + + + + + + for _, r := range d.Results { + + + + + + + + + } + +
Title + Price + Store + Found + Alert
+ if r.ImageURL != "" { + + } + + if r.URL != "" { + { r.Title } + } else { + { r.Title } + } + if r.MatchedQuery != "" { +
via "{ r.MatchedQuery }"
+ } +
{ fmtPrice(r.Price, r.Currency) }{ r.Source }{ humanTime(r.FoundAt) } + if r.Alerted { + sent + } +
+
+ if d.TotalPages > 1 { +
+ for i := 1; i <= d.TotalPages; i++ { + { fmt.Sprintf("%d", i) } + } +
+ } +
+} + +func pageClass(i, current int) string { + if i == current { + return "v-btn" + } + return "v-btn-ghost" +} + +func toggleOrder(current, axis string) string { + switch axis { + case "price": + if current == "price_asc" { + return "price_desc" + } + return "price_asc" + case "found": + if current == "found_desc" || current == "" { + return "found_asc" + } + return "found_desc" + } + return "" +} + +templ ItemResults(d ItemResultsData) { + @Layout(d.Page, itemResultsBody(d)) +} + +templ globalResultsBody(d GlobalResultsData) { +
+

All Results

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + + + for _, r := range d.Results { + + + + + + + + + } + +
ItemTitlePriceStoreFoundAlert
{ r.ItemName } + if r.URL != "" { + { r.Title } + } else { + { r.Title } + } + if r.MatchedQuery != "" { +
via "{ r.MatchedQuery }"
+ } +
{ fmtPrice(r.Price, r.Currency) }{ r.Source }{ humanTime(r.FoundAt) } + if r.Alerted { + sent + } +
+
+
+} + +templ GlobalResults(d GlobalResultsData) { + @Layout(d.Page, globalResultsBody(d)) +} + +// ChartJSON helper for handlers. +type ChartJSON struct { + Labels []string `json:"labels"` + Points []float64 `json:"points"` +} + +func MustChartJSON(c ChartJSON) string { + b, _ := json.Marshal(c) + return string(b) +} diff --git a/templates/results_templ.go b/templates/results_templ.go new file mode 100644 index 0000000..7147d10 --- /dev/null +++ b/templates/results_templ.go @@ -0,0 +1,866 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "encoding/json" + "fmt" + + "veola/internal/models" +) + +type ItemResultsData struct { + Page + Item models.Item + Badge BadgeData + History []models.PricePoint + Results []models.Result + Page_ int + TotalPages int + Order string + HistoryChartJSON string +} + +type BadgeData struct { + Label string + Class string // v-badge-low / v-badge-avg / v-badge-target / "" +} + +type GlobalResultsData struct { + Page + Items []models.Item + Results []ItemResultRow + ItemID int64 + From string + To string +} + +type ItemResultRow struct { + models.Result + ItemName string +} + +func itemResultsBody(d ItemResultsData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 45, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Item.Category != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Category) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 47, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Item.BestPrice != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(d.Item.BestPrice, "USD")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 52, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Item.BestPriceURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.BestPriceStore) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 54, Col: 123} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
no price yet
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.Badge.Label != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 = []any{"v-badge", d.Badge.Class} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(d.Badge.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 61, Col: 62} + } + _, 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, 15, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

Price History

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(d.History) < 2 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
Not enough history yet.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, r := range d.Results { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 = []any{"font-mono", priceClass(r.Price, d.Item.TargetPrice)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...) + 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
TitlePriceStoreFoundAlert
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.ImageURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\"\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.URL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 137, Col: 82} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + 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 { + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 139, Col: 18} + } + _, 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, 31, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if r.MatchedQuery != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
via \"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 142, Col: 59} + } + _, 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, 33, "\"
") + 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 + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 145, Col: 105} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 146, Col: 37} + } + _, 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 + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 147, Col: 57} + } + _, 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, 39, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.Alerted { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "sent") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.TotalPages > 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for i := 1; i <= d.TotalPages; i++ { + var templ_7745c5c3_Var24 = []any{pageClass(i, d.Page_)} + 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, 44, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", i)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 161, Col: 159} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func pageClass(i, current int) string { + if i == current { + return "v-btn" + } + return "v-btn-ghost" +} + +func toggleOrder(current, axis string) string { + switch axis { + case "price": + if current == "price_asc" { + return "price_desc" + } + return "price_asc" + case "found": + if current == "found_desc" || current == "" { + return "found_asc" + } + return "found_desc" + } + return "" +} + +func ItemResults(d ItemResultsData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var28 := templ.GetChildren(ctx) + if templ_7745c5c3_Var28 == nil { + templ_7745c5c3_Var28 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = Layout(d.Page, itemResultsBody(d)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func globalResultsBody(d GlobalResultsData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var29 := templ.GetChildren(ctx) + if templ_7745c5c3_Var29 == nil { + templ_7745c5c3_Var29 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

All Results

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, r := range d.Results { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
ItemTitlePriceStoreFoundAlert
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 226, Col: 93} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.URL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 229, Col: 82} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 231, Col: 18} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if r.MatchedQuery != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
via \"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 234, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 237, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 238, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 239, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.Alerted { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "sent") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func GlobalResults(d GlobalResultsData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var43 := templ.GetChildren(ctx) + if templ_7745c5c3_Var43 == nil { + templ_7745c5c3_Var43 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = Layout(d.Page, globalResultsBody(d)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// ChartJSON helper for handlers. +type ChartJSON struct { + Labels []string `json:"labels"` + Points []float64 `json:"points"` +} + +func MustChartJSON(c ChartJSON) string { + b, _ := json.Marshal(c) + return string(b) +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/settings.templ b/templates/settings.templ new file mode 100644 index 0000000..7bbc8e2 --- /dev/null +++ b/templates/settings.templ @@ -0,0 +1,156 @@ +package templates + +import ( + "fmt" + + "veola/internal/models" +) + +type SettingsData struct { + Page + Values map[string]string + IsAdmin bool + Users []models.User + TestNtfyOK string + TestApifyOK string + PasswordMsg string + PasswordError string + UserMsg string + UserError string +} + +templ settingsBody(d SettingsData) { +
+

Settings

+ +
+

Apify and Ntfy

+
+ @CSRFInput(d.CSRFToken) +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ if !d.IsAdmin { +
Read-only for non-admin users.
+ } else { +
+ + + +
+ } +
+ if d.TestNtfyOK != "" { +
{ d.TestNtfyOK }
+ } + if d.TestApifyOK != "" { +
{ d.TestApifyOK }
+ } +
+ +
+

Change Password

+ if d.PasswordError != "" { +
{ d.PasswordError }
+ } + if d.PasswordMsg != "" { +
{ d.PasswordMsg }
+ } +
+ @CSRFInput(d.CSRFToken) +
+ + +
+
+ + +
+
+ + +
+ +
+
+ + if d.IsAdmin { +
+

Users

+ if d.UserError != "" { +
{ d.UserError }
+ } + if d.UserMsg != "" { +
{ d.UserMsg }
+ } + + + + for _, u := range d.Users { + + + + + + + } + +
UsernameRoleCreated
{ u.Username }{ string(u.Role) }{ u.CreatedAt.Format("2006-01-02") } +
+ + + +
+
+ + +
+
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+ } +
+} + +templ Settings(d SettingsData) { + @Layout(d.Page, settingsBody(d)) +} diff --git a/templates/settings_templ.go b/templates/settings_templ.go new file mode 100644 index 0000000..8627720 --- /dev/null +++ b/templates/settings_templ.go @@ -0,0 +1,447 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + + "veola/internal/models" +) + +type SettingsData struct { + Page + Values map[string]string + IsAdmin bool + Users []models.User + TestNtfyOK string + TestApifyOK string + PasswordMsg string + PasswordError string + UserMsg string + UserError string +} + +func settingsBody(d SettingsData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Settings

Apify and Ntfy

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CSRFInput(d.CSRFToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !d.IsAdmin { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
Read-only for non-admin users.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.TestNtfyOK != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 65, Col: 44} + } + _, 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, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.TestApifyOK != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 68, Col: 45} + } + _, 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, 15, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

Change Password

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.PasswordError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 75, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.PasswordMsg != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 78, Col: 40} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CSRFInput(d.CSRFToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.IsAdmin { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "

Users

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.UserError != "" { + 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(d.UserError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 102, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.UserMsg != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 105, Col: 37} + } + _, 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, 27, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, u := range d.Users { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
UsernameRoleCreated
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 112, Col: 24} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + 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 + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 113, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 114, Col: 70} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func Settings(d SettingsData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var22 := templ.GetChildren(ctx) + if templ_7745c5c3_Var22 == nil { + templ_7745c5c3_Var22 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/veola-spec.md b/veola-spec.md new file mode 100644 index 0000000..acbf55d --- /dev/null +++ b/veola-spec.md @@ -0,0 +1,786 @@ +# Veola — Claude Code Reference Specification + +> DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE + +This document is the authoritative specification for a self-hosted Go web application that tracks items across e-commerce platforms and delivers deal alerts via push notification. It is written for Claude Code and should be followed precisely. + +--- + +## Overview + +A Go web application named Veola for tracking specific items across multiple e-commerce platforms (eBay, Amazon, etc.) via the Apify API. The operator adds items to watch via a web interface, configures per-item alert thresholds, and receives push notifications via a self-hosted Ntfy instance when deals are found. All configuration, results, and history are managed through the web UI. + +--- + +## Tech Stack + +| Layer | Choice | Notes | +|---|---|---| +| Language | Go 1.22+ | Standard library preferred where possible | +| Templates | [a-h/templ](https://github.com/a-h/templ) | Type-safe HTML components | +| Reactivity | [HTMX](https://htmx.org) | Via CDN, no build step | +| CSS | [Tailwind CSS](https://tailwindcss.com) | Via CDN Play CDN for development; document where to swap for production build | +| Database | SQLite | Use `modernc.org/sqlite` (pure Go, no CGO required) | +| HTTP router | `net/http` + `chi` | `github.com/go-chi/chi/v5` | +| Config | TOML config file | Use `github.com/BurntSushi/toml` | +| Scheduler | `robfig/cron` | `github.com/robfig/cron/v3` | + +--- + +## Visual Design Direction + +The app is named Veola, after the magic shop owner from Atelier Iris: Eternal Mana. Taciturn, precise, and better at the job than anyone else in the room. The aesthetic should reflect that: no-nonsense, visually distinctive, purpose-built. + +**Direction: Sega blue** + +The reference point is the Sega Genesis/Mega Drive hardware era — the distinctive saturated blue of the console body, the bright Sega logo blue, and the high-contrast white text of that period's UI. Not dark mode, not light mode — blue mode. + +Color palette: +- Background: `#1a2b6d` (Genesis console blue — deep, saturated, not navy) +- Surface/cards: `#1f3380` (slightly lighter, enough contrast to lift cards off the background) +- Primary accent: `#00a4e4` (Sega logo blue — bright, electric) +- Secondary accent: `#f5c400` (Sega arcade button yellow — used very sparingly for price highlights and active states) +- Text primary: `#ffffff` +- Text secondary: `#a8c0f0` (desaturated blue-white for secondary labels and timestamps) +- Danger/error: `#e84040` (bright red — high contrast against blue) +- Success/below-target price: `#00e4a4` (mint, complementary to the Sega blue) +- Border: `rgba(255, 255, 255, 0.12)` (subtle white rule) + +Typography: +- Monospaced font for prices and data values: `JetBrains Mono` or `Fira Code` via Google Fonts +- UI chrome: `Outfit` or `DM Sans` — clean, slightly geometric, fits the era +- Do not use Inter, Roboto, or system fonts + +Surface treatment: +- Cards: solid `#1f3380` background, `1px` border at `rgba(255,255,255,0.12)`, `border-radius: 8px` +- No glassmorphism, no blur effects, no texture — solid surfaces only +- No texture effects — clean flat surfaces throughout +- Box shadows use blue tones, not black: `0 4px 16px rgba(0, 0, 80, 0.4)` + +Component specifics: +- Status pills: Active (`#00a4e4` background, white text), Paused (white/10 background, `#a8c0f0` text), Error (`#e84040` background, white text) +- Prices: `JetBrains Mono`, large, `#f5c400` when at or below target, `#ffffff` otherwise +- Primary buttons: `#00a4e4` background, white text, no rounded pill — `border-radius: 6px` +- Navigation active state: `#00a4e4` left border accent, slightly lighter surface + +Do not introduce purple. Do not use gradients on backgrounds. Do not produce a generic dark SaaS dashboard with blue accents — the background itself is blue, which is the whole point. + +--- + +## Application Structure + +``` +veola/ +├── main.go +├── config.toml.example +├── internal/ +│ ├── config/ +│ │ └── config.go # TOML config loading and validation +│ ├── db/ +│ │ ├── db.go # SQLite init, migrations +│ │ └── queries.go # All DB operations +│ ├── models/ +│ │ └── models.go # Item, Result, PricePoint, Setting structs +│ ├── apify/ +│ │ └── client.go # Apify API client +│ ├── ntfy/ +│ │ └── client.go # Ntfy push client +│ ├── auth/ +│ │ └── auth.go # Session management, bcrypt, middleware +│ ├── crypto/ +│ │ └── crypto.go # AES-256-GCM encrypt/decrypt, key derivation +│ ├── scheduler/ +│ │ └── scheduler.go # Cron-based poll scheduler +│ └── handlers/ +│ ├── items.go # Item CRUD + preview handlers +│ ├── results.go # Results view handlers +│ ├── settings.go # Settings handlers +│ ├── auth.go # Login/logout handlers +│ └── dashboard.go # Dashboard handler +├── templates/ +│ ├── layout.templ # Base layout, nav +│ ├── login.templ # Login page +│ ├── dashboard.templ +│ ├── items.templ +│ ├── item_form.templ +│ ├── item_preview.templ # Preview results before confirming add +│ ├── results.templ +│ └── settings.templ +└── static/ + └── (any local static assets if needed) +``` + +--- + +## Database Schema + +All migrations run at startup via embedded SQL. Use `modernc.org/sqlite` with WAL mode enabled. + +```sql +PRAGMA journal_mode=WAL; +PRAGMA foreign_keys=ON; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, -- bcrypt hash, cost factor 12 + role TEXT NOT NULL DEFAULT 'user', -- 'admin' or 'user' + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + search_query TEXT, -- free text search (optional) + url TEXT, -- specific product URL (optional) + category TEXT, -- user-defined label + target_price REAL, -- alert threshold (NULL = alert on any result) + ntfy_topic TEXT NOT NULL, -- per-item ntfy topic slug + ntfy_priority TEXT DEFAULT 'default', -- min, low, default, high, urgent + poll_interval_minutes INTEGER DEFAULT 60, + include_out_of_stock INTEGER DEFAULT 0, + active INTEGER DEFAULT 1, -- 0 = paused + last_polled_at DATETIME, -- timestamp of last completed poll + last_poll_error TEXT, -- most recent error message, NULL if last poll succeeded + best_price REAL, -- lowest price seen in most recent poll + best_price_store TEXT, -- store name for best_price + best_price_url TEXT, -- direct listing URL for best_price + best_price_image_url TEXT, -- product image URL from best result + best_price_title TEXT, -- product title from best result + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + title TEXT, + price REAL, + currency TEXT NOT NULL, -- ISO 4217 code: USD, JPY, GBP, EUR, etc. Never assume USD. + url TEXT, + source TEXT, -- e.g. "ebay", "amazon" + image_url TEXT, + alerted INTEGER DEFAULT 0, -- 1 = ntfy notification sent + found_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- price_history records the best price observed per item per poll cycle. +-- Used to render the price history chart. One row per poll run. +CREATE TABLE IF NOT EXISTS price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + price REAL NOT NULL, -- best (lowest) price seen in this poll + store TEXT, + polled_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Seed default settings keys (values populated via UI or env) +INSERT OR IGNORE INTO settings (key, value) VALUES + ('apify_api_key', ''), + ('ntfy_base_url', ''), + ('ntfy_default_topic', 'veola'), + ('global_poll_interval_minutes', '60'), + ('match_confidence_threshold', '0.6'); +``` + +--- + +## Configuration + +Config is loaded from a TOML file. The path defaults to `./config.toml` and can be overridden with the `-config` flag at startup: + +``` +./veola -config /etc/veola/config.toml +``` + +The config file is the single source of truth. There are no environment variable overrides and no DB-stored settings that duplicate what is in the file — the Settings UI stores only Apify and Ntfy operational settings in the DB (API keys, topics, thresholds). Server and security settings live in the file only. + +`config.toml.example`: + +```toml +[server] +port = 8080 +db_path = "./veola.db" + +[security] +session_secret = "change-this-to-a-random-32-byte-string" +encryption_key = "change-this-to-a-different-random-32-byte-string" + +[apify] +api_key = "" + +[apify.actors] +active_listings = "harvestlab/ebay-scraper" +sold_listings = "automation-lab/ebay-sold-scraper" +price_comparison = "junipr/price-comparison" + +[ntfy] +base_url = "https://ntfy.yourdomain.com" +default_topic = "veola" + +[scheduler] +global_poll_interval_minutes = 60 +match_confidence_threshold = 0.6 +``` + +The Go config struct mirrors this structure exactly: + +```go +type Config struct { + Server ServerConfig `toml:"server"` + Security SecurityConfig `toml:"security"` + Apify ApifyConfig `toml:"apify"` + Ntfy NtfyConfig `toml:"ntfy"` + Scheduler SchedulerConfig `toml:"scheduler"` +} +``` + +Startup validation (exit on failure): +- `security.session_secret` must be at least 32 bytes +- `security.encryption_key` must be at least 32 bytes +- `security.session_secret` and `security.encryption_key` must not be equal +- `server.db_path` must be a writable path +- Config file must exist and parse without error; missing file is a fatal error with a clear message pointing to `config.toml.example` + +--- + +## Authentication + +Single-user setup for v1 but the schema and auth layer support multiple users from the start. All users share the same item list — items are not scoped per user. Each user manages their own ntfy topic subscription independently. + +**User roles:** +- `admin` — can manage users (add, remove, reset passwords), access all settings +- `user` — can manage items, view results, view settings (read-only) + +The first account created via `/setup` is automatically assigned the `admin` role. Subsequent accounts are created by an admin from the Settings page — no self-registration. + +**Package: `internal/auth/auth.go`** + +- Password hashing: `bcrypt` at cost factor 12 (`golang.org/x/crypto/bcrypt`) +- Sessions: signed cookies using `gorilla/sessions` with `SESSION_SECRET` from env +- Session lifetime: 7 days, renewed on each request +- All routes except `/login` and `/setup` are protected by auth middleware +- Middleware checks for a valid session cookie; redirects to `/login` on failure + +**First-run setup:** +- On startup, if the `users` table is empty, the app redirects all requests to `/setup` +- `/setup` renders a form to create the initial username and password +- Password must be at least 12 characters — validate server-side +- After setup completes, redirect to `/login` +- `/setup` is inaccessible once a user exists (return 404) + +**Login (`/login`):** +- Simple form: username + password +- On success: create session, redirect to `/` +- On failure: show "Invalid username or password" (do not indicate which field was wrong) +- No rate limiting required for v1 (single-user, self-hosted) + +**Logout (`/logout`):** +- POST only (CSRF-safe) +- Clears session, redirects to `/login` + +**Password change:** +- Available in Settings page +- Requires current password confirmation before accepting new password + +--- + +## Data Encryption + +Application-level column encryption using AES-256-GCM. The SQLite file remains unencrypted at the filesystem level; sensitive values are encrypted before write and decrypted after read in the query layer. No CGO required. + +**Package: `internal/crypto/crypto.go`** + +Key derivation: +- Raw key material comes from `security.encryption_key` in `config.toml` (must be at least 32 bytes; app exits on startup if absent or too short) +- Derive a 32-byte AES key using HKDF-SHA256 (`golang.org/x/crypto/hkdf`) with a fixed info string of `"veola-v1"` +- The derived key is held in memory for the lifetime of the process; never written to disk + +Encryption scheme: +- Algorithm: AES-256-GCM (`crypto/aes`, `crypto/cipher`) +- Nonce: 12 bytes, randomly generated per encryption call (`crypto/rand`) +- Storage format: base64(nonce + ciphertext + tag), stored as TEXT in SQLite +- Prefix encrypted values with the string `enc:` so the query layer can distinguish encrypted from plaintext values (relevant during any future migration) + +```go +// Public interface +func Encrypt(key []byte, plaintext string) (string, error) +func Decrypt(key []byte, ciphertext string) (string, error) +func IsEncrypted(value string) bool // checks for "enc:" prefix +``` + +**Encrypted fields:** + +| Table | Column | +|---|---| +| `settings` | `value` (all rows) | +| `items` | `search_query`, `url`, `ntfy_topic`, `best_price_url`, `best_price_image_url`, `best_price_title`, `last_poll_error` | +| `results` | `title`, `url`, `image_url` | +| `price_history` | `store` | +| `users` | `username` | + +Fields not encrypted: all numeric values (`price`, `id`, `poll_interval_minutes`, etc.), boolean flags, timestamps, and `currency`. These carry no sensitive information and encrypting them would break sorting and range queries. + +**Query layer integration:** +- All encrypt/decrypt calls happen in `internal/db/queries.go` — never in handlers or templates +- Structs in `internal/models/models.go` always hold decrypted plaintext values +- If decryption fails for a field, log the error and substitute an empty string — do not crash or return an error to the UI for individual field failures +- On write: encrypt then store. On read: read then decrypt. + +**Key rotation (document only, do not implement in v1):** +- Future: add a `/admin/reencrypt` endpoint that reads all encrypted rows with the old key and rewrites them with a new key. Not in scope for v1. + +**Startup validation** is handled by the config loader (see Configuration section) — the crypto package receives an already-validated key and may assume it is correct length. `SESSION_SECRET` and `ENCRYPTION_KEY` must be different values — validated at config load time. + +--- + +## Apify Integration + +**Client: `internal/apify/client.go`** + +Veola uses two distinct actor types per item, serving different purposes. Both share the same underlying API client but with different input schemas and result parsers. + +### API Flow (both actor types) + +1. Start a run via `POST https://api.apify.com/v2/acts/{actorId}/runs` +2. Poll run status via `GET https://api.apify.com/v2/actor-runs/{runId}` until `SUCCEEDED` or `FAILED` +3. Fetch results via `GET https://api.apify.com/v2/actor-runs/{runId}/dataset/items` +4. Return structured results + +All requests authenticated via `?token={apiKey}` query parameter. Run timeout: 5 minutes. API key and all actor IDs loaded from config — never hardcoded. + +### Actor 1 — Active Listings (primary, polling) + +Default actor: `harvestlab/ebay-scraper` + +Used on every scheduled poll cycle. Finds currently available listings — what you can actually buy right now. + +```go +type ActiveListingInput struct { + Query string `json:"query,omitempty"` + URL string `json:"url,omitempty"` + Marketplace string `json:"marketplace,omitempty"` // e.g. "ebay.com", "ebay.co.uk" + ListingType string `json:"listingType,omitempty"` // "BIN", "auction", "all" + MaxResults int `json:"maxResults,omitempty"` +} + +type ActiveListingResult struct { + Title string `json:"title"` + Price float64 `json:"price"` + Currency string `json:"currency"` + URL string `json:"url"` + Store string `json:"store"` + ImageURL string `json:"imageUrl"` + Condition string `json:"condition"` + ListingType string `json:"listingType"` + ShippingPrice float64 `json:"shippingPrice"` + FreeShipping bool `json:"freeShipping"` + Marketplace string `json:"marketplace"` + MatchConfidence float64 `json:"matchConfidence"` +} +``` + +- Default marketplace: `ebay.com` (configurable per item) +- Default listing type: `all` (configurable per item) +- Minimum `matchConfidence`: 0.6 (configurable in settings) +- Filter out out-of-stock results by default (configurable per item) + +### Actor 2 — Sold Listings (historical baseline) + +Default actor: `automation-lab/ebay-sold-scraper` + +Used in two scenarios only: +1. On item creation — seeds `price_history` with real completed sale prices before the first active listing poll runs +2. Weekly refresh — keeps the historical baseline current with recent sold data + +This ensures deal quality badges ("X% below 30-day average") are based on what things actually sell for, not asking price snapshots. + +```go +type SoldListingInput struct { + Query string `json:"query"` + Marketplace string `json:"marketplace,omitempty"` + MaxResults int `json:"maxResults,omitempty"` + DaysBack int `json:"daysBack,omitempty"` // default 30 +} + +type SoldListingResult struct { + Title string `json:"title"` + SoldPrice float64 `json:"soldPrice"` + Currency string `json:"soldCurrency"` + SoldAt string `json:"endedAt"` + Condition string `json:"condition"` + ListingType string `json:"listingType"` + ShippingPrice float64 `json:"shippingPrice"` + URL string `json:"url"` +} +``` + +On item creation, insert one `price_history` row per sold result using `soldPrice` and `endedAt` as the timestamp. This gives deal quality badges real data immediately rather than waiting weeks for polling history to accumulate. + +### Actor 3 — General Price Comparison (secondary, optional) + +Default actor: `junipr/price-comparison` + +Covers Amazon, Walmart, Target, Best Buy, Home Depot. Not eBay. Used as a supplementary source for items where broader retail coverage is wanted alongside eBay. Not enabled by default — configurable per item in the advanced section of the item form. + +```go +type PriceComparisonInput struct { + Query string `json:"query,omitempty"` + URL string `json:"url,omitempty"` + MatchStrictness string `json:"matchStrictness,omitempty"` // "strict", "normal", "loose" +} + +type PriceComparisonResult struct { + Title string `json:"title"` + Price float64 `json:"price"` + Currency string `json:"currency"` + URL string `json:"url"` + Store string `json:"store"` + ImageURL string `json:"imageUrl"` + Availability string `json:"availability"` + MatchConfidence float64 `json:"matchConfidence"` +} +``` + +### Actor 4 — Yahoo Auctions Japan / JDirectItems (Japanese marketplace) + +Default actor: `styleindexamerica/jp-yahooauctions-scraper` + +Scrapes `auctions.yahoo.co.jp` directly. Known internationally as JDirectItems Auction since January 2025 — the underlying site and URL are unchanged. + +**Important:** Yahoo Auctions Japan is Japanese-language only. Search queries for this marketplace must be in Japanese (kanji/kana) or romaji to return meaningful results. The item form must display a visible warning when this actor is selected: "Yahoo Auctions Japan searches in Japanese. English queries will return few or no results." + +**CJK input requirements:** All text input fields in the item form must handle Japanese IME input correctly. Specifically: +- Do not trigger search, validation, or HTMX requests on `keydown` or `keyup` events — use `change` or explicit button actions only. IME composition fires intermediate key events that will break mid-input if the app reacts to them. +- Set `lang` attribute appropriately on inputs where Japanese is expected. +- Do not truncate, strip, or re-encode non-ASCII characters at any layer — Go, SQLite, and the template layer must all pass CJK strings through unchanged. +- Test strings: `ツインビー`, `グラディウス`, `パロディウス` — if these round-trip through the form, DB, and display correctly the implementation is sound. + +A companion sold-price actor is also available for historical baseline seeding: `contented_eyebrow/yafuoku-closedsearch-crawler` + +```go +type YahooAuctionsJPInput struct { + Query string `json:"query"` + MaxItems int `json:"maxItems,omitempty"` +} + +type YahooAuctionsJPResult struct { + Title string `json:"name"` + Price float64 `json:"currentPrice"` + Currency string `json:"currency"` // JPY + URL string `json:"url"` + ImageURL string `json:"imageUrl"` + BidsCount int `json:"bidsCount"` + TimeLeft string `json:"timeLeft"` + EndTime string `json:"endTime"` +} +``` + +Currency will be JPY — the results layer must store and display the original currency rather than assuming USD. + +### Actor 5 — Mercari Japan + +Default actor: community Mercari Japan scraper (verify current best actor on Apify Store before implementation — search "mercari japan" sorted by run count, pick highest with 90%+ success rate and update within 90 days) + +Mercari Japan is a large secondhand marketplace popular for figures, games, and collectibles. Same Japanese-language caveat applies as Yahoo Auctions Japan. + +### Config + +```toml +[apify] +api_key = "" + +[apify.actors] +active_listings = "harvestlab/ebay-scraper" +sold_listings = "automation-lab/ebay-sold-scraper" +price_comparison = "junipr/price-comparison" +yahoo_auctions_jp = "styleindexamerica/jp-yahooauctions-scraper" +yahoo_auctions_jp_sold = "contented_eyebrow/yafuoku-closedsearch-crawler" +mercari_jp = "" # populate before use — see actor selection note above +``` + +All three actor IDs are overridable per item in the item form (advanced section, collapsed by default). + +--- + +## Ntfy Integration + +**Client: `internal/ntfy/client.go`** + +Post a JSON notification to the self-hosted Ntfy instance. + +```go +type Notification struct { + Topic string `json:"topic"` + Title string `json:"title"` + Message string `json:"message"` + Priority string `json:"priority"` // min, low, default, high, urgent + Tags []string `json:"tags"` + Click string `json:"click,omitempty"` // URL to open on tap +} +``` + +- Base URL is configurable (self-hosted) +- Topic is per-item +- `Click` should be the product listing URL so tapping the notification opens the deal +- Tags: use `["shopping_cart", "tada"]` on a price-threshold hit, `["mag"]` on a general result +- Include price and source in the message body +- Mark `result.alerted = 1` in DB after successful send + +Example notification for a hit: +``` +Title: Veola Alert: TwinBee Famicom CIB +Message: eBay — $42.00 (target: $60.00) + Buy It Now · Free Shipping +Click: https://ebay.com/itm/... +``` + +--- + +## Scheduler + +**`internal/scheduler/scheduler.go`** + +- Use `robfig/cron/v3` with second-level precision disabled (minute granularity is fine) +- On startup, load all active items from DB and register a cron job per item based on `poll_interval_minutes` +- When an item is created, updated, or toggled active/paused, re-register or remove its cron job without restarting the app +- Each job: invoke Apify search, store results in DB, evaluate alert conditions, send Ntfy if triggered +- Log each poll cycle with item name, result count, and whether an alert was sent +- Jobs run with a context that is cancelled on app shutdown (graceful shutdown required) + +Alert condition logic: +``` +if item.TargetPrice == nil: + alert on every new result not previously seen +else: + alert only when result.Price <= item.TargetPrice +``` + +Deduplication: a result is "previously seen" if a row exists in `results` with the same `item_id` and `url`. Do not re-alert the same listing. + +--- + +## Web UI — Pages and Handlers + +### Layout (`layout.templ`) +- Fixed left sidebar navigation on desktop, collapsible on mobile +- Nav items: Dashboard, Items, Results, Settings +- Header shows app name "Veola" with a small bee emoji or custom SVG icon +- Active nav item highlighted with the primary accent color +- No top navbar — sidebar only + +### Dashboard (`/`) +- Summary cards: Total Items Tracked, Active Items, Results Today, Alerts Sent Today +- **Potential Spend card**: sum of `best_price` across all active items with a known best price. Label: "Potential Spend" with a subline showing the item count contributing to the total (e.g. "across 12 items"). Displayed in large monospaced font. Items with no best price yet (never polled or no results) are excluded from the total and noted: "3 items not yet priced." +- **Money Saved card**: sum of `(historical_average_price - best_price)` across all active items where both values are known. Historical average is the mean of all `price_history` rows for that item. Only include items where `best_price` is below their historical average — negative savings are not counted. Subline shows contributing item count. Displayed in large monospaced font in the success color (`#00e4a4`). +- Recent Results table: last 20 results across all items, showing name, price, source, found time, alert status +- Recent Alerts section: last 5 ntfy alerts sent, with item name and price +- All data loaded server-side; HTMX auto-refresh every 60 seconds (`hx-trigger="every 60s"`) + +### Items List (`/items`) +- Table/card list of all tracked items +- Columns: Name, Category, Target Price, Best Price (with store name as a link to the listing), Last Polled, Status (Active/Paused/Error), Actions +- Best Price cell: show price in large monospaced font, store name beneath it as a link, highlighted mint green if at or below target price +- Last Polled cell: human-readable relative time (e.g. "12 minutes ago"); tooltip shows exact timestamp +- Error badge: if `last_poll_error` is set, show a small coral warning badge on the row; clicking it shows the error message inline via HTMX +- Actions: Edit, Pause/Resume toggle (HTMX inline toggle, no page reload), Delete (with confirm), Run Now +- "Add Item" button opens Step 1 of the item form (HTMX swap into a modal or slide panel) +- Filter by category (simple dropdown, no JS required — HTMX get) + +### Add Item Flow (Two-Step) + +The add item flow is a two-step process. The item is not saved to the database until the user confirms after seeing the preview. + +**Step 1 — Entry form (`/items/new`)** + +Fields: +- Name (required) +- Category (text, optional — HTMX autocomplete from existing categories) +- Search Query (textarea, optional) +- Product URL (text input, optional) +- At least one of Search Query or URL is required — validate server-side before running preview +- Target Price (number, optional — blank means alert on any result) +- Ntfy Topic (pre-populated as a slug from Name, editable) +- Ntfy Priority (select: min / low / default / high / urgent) +- Poll Interval (select: 15min / 30min / 1hr / 2hr / 6hr / 12hr / 24hr) +- Include Out of Stock (checkbox, default unchecked) + +The primary action button is "Preview" — not "Save". Clicking it submits the form via HTMX POST to `/items/preview`. + +**Step 2 — Preview (`POST /items/preview`)** + +Server validates the form, runs an Apify search immediately, and returns a preview partial (`item_preview.templ`) swapped into the modal/panel. The original form values are preserved as hidden fields in the confirmation form. + +The preview partial displays: +- A header: "Found {N} results for '{query}'" +- Best result card (largest, top of preview): + - Product image (if available) on the left + - Title, store name (as a link to the listing), and price on the right + - Price in large monospaced font with the currency symbol + - "Best Price" label above the price in mint green +- Remaining results as a compact list below the best result card: + - Each row: image thumbnail, title (truncated), store (linked), price + - Show up to 5 additional results + - If more than 6 results exist, show "and {N} more" below the list +- Price range summary line: "Prices range from $X.XX to $Y.YY across {N} stores" +- If no results found: show a warning card "No results found. Try a broader search query." with a Back button only +- Two action buttons at the bottom: + - "Back" — returns to the entry form with fields repopulated (HTMX swap) + - "Confirm and Track" — POST to `/items` to save the item and start tracking + +**Step 2 error states:** +- Apify timeout or API error: show error message with a Back button +- Zero results after filtering: show warning with Back button + +**`POST /items` (confirm save)** +- Validates all fields again server-side +- Saves item to DB +- Registers cron job in scheduler +- Records initial `best_price`, `best_price_store`, `best_price_url`, `best_price_image_url`, `best_price_title` from the preview results (passed as hidden fields or re-fetched — re-fetching is acceptable) +- Writes one row to `price_history` for the initial poll +- Sets `last_polled_at` to now +- Redirects to the new item's results page + +### Edit Item (`/items/{id}/edit`) +- Same fields as Step 1 of the add flow +- No preview step on edit — changes save directly +- Validation errors shown inline via HTMX + +### Results and Price History (`/items/{id}/results`) +- Item summary header: name, category, target price, best current price (store + link), last polled timestamp +- **Deal Quality badge**: displayed prominently in the header next to the best price. Show the single highest-priority badge that applies, evaluated in this order: + 1. "All-time low" (mint green, `#00e4a4`) — current `best_price` is the lowest value ever recorded in `price_history` for this item + 2. "X% below 30-day avg" (Sega blue accent, `#00a4e4`) — current `best_price` is at least 10% below the mean of `price_history` rows from the last 30 days. X is the calculated percentage, rounded to the nearest whole number. + 3. "X% below target" (yellow, `#f5c400`) — a `target_price` is set and `best_price` is below it. X is the percentage difference. + - Show no badge if none of the conditions are met. Do not show multiple badges simultaneously. +- Price History chart: line chart rendered via Chart.js (CDN). X axis is poll date/time, Y axis is price. One data point per `price_history` row. Chart is dark-themed to match the app aesthetic (dark background, mint green line, coral point markers). If fewer than 2 data points exist, show "Not enough history yet" instead of the chart. +- Results table below the chart: + - Columns: Image (thumbnail), Title, Price, Store (linked), Found, Alert Sent + - Price highlighted in mint green if at or below target + - Sortable by price and found date (server-side, via HTMX get with sort params) + - Pagination (20 per page) +- "Run Now" button in the header — triggers an immediate Apify poll, updates best price fields, appends to price_history, refreshes the chart and table via HTMX + +### Global Results (`/results`) +- All results across all items, most recent first +- Columns: Item Name, Title, Price, Store, Found, Alert Sent +- Filterable by item and date range + +### Settings (`/settings`) +- Form fields for all configurable settings: + - Apify API Key (password input, masked) + - Ntfy Base URL + - Ntfy Default Topic + - Global Poll Interval + - Match Confidence Threshold (slider or number input, 0.0–1.0) + - Note: actor IDs are configured in `config.toml` and in per-item advanced settings, not here +- "Test Ntfy" button — sends a test notification to the default topic, result shown inline +- "Test Apify" button — runs a sample search ("test query"), shows raw result count inline +- Save persists to DB settings table +- Password Change section (separate from the above): + - Current Password + - New Password (min 12 characters) + - Confirm New Password + - Saved independently from other settings +- User Management section (admin only): + - List of all users with username, role, and created date + - "Add User" form: username, role (admin/user), initial password + - "Remove User" button per user (cannot remove yourself) + - "Reset Password" button per user — admin sets a new password directly, no current password required + +--- + +## Error Handling + +- All Apify failures: log error, mark item `last_poll_error` (add TEXT column to items), surface in UI as a warning badge +- All Ntfy failures: log error, do not mark result as alerted, retry on next poll cycle +- DB errors: log and return 500 with a friendly error page (no stack traces in UI) +- Context cancellation on shutdown: in-progress Apify polls should be abandoned cleanly + +--- + +## Graceful Shutdown + +- Listen for SIGINT/SIGTERM +- Stop cron scheduler (waits for running jobs to complete) +- Close DB connection +- Log "Veola shutting down" + +--- + +## Editorial and Code Style Rules + +- No em dashes anywhere in UI copy or log messages. Use a plain hyphen or restructure the sentence. +- No explaining jokes or results to the user. Show the data, let them decide. +- Log lines should be structured (key=value pairs), not prose sentences. +- Do not add unrequested features. Build exactly what is specified. +- Do not rewrite or restructure this document. +- Comments in Go code should be terse and factual. +- All configurable values (API keys, URLs, actor IDs, model names) must flow through the config struct — never hardcoded. +- Templ components should be small and composable. Avoid monolithic template files. + +--- + +## Out of Scope (Do Not Implement) + +- Matrix posting (future phase) +- Discord integration +- Email alerting +- Mobile app + +--- + +## Suggested Implementation Order for Claude Code + +1. DB schema and migrations +2. Config loading +3. Crypto package (AES-256-GCM, key derivation, startup validation) +4. Auth package (bcrypt, sessions, middleware) +5. Login/setup/logout handlers and templates +6. Models and DB query layer (encrypt/decrypt wired in) +7. Apify client (with a test harness) +8. Ntfy client +9. Templ layout + base styles (auth-aware nav) +10. Items list handler and template +11. Add item Step 1 form +12. Add item Step 2 preview handler and template +13. Confirm save handler (wires to scheduler) +14. Edit item handler +15. Settings page (including password change) +16. Scheduler wired to Apify + Ntfy + price_history writes +17. Dashboard +18. Per-item results page with Chart.js price history +19. Global results view +20. "Run Now" HTMX endpoint +21. Graceful shutdown + +--- + +## Agreed Deviations from this Spec + +These items were discussed with the operator on 2026-05-10 before implementation and override the corresponding sections above. They are appended rather than edited in place to preserve the original spec text. + +1. **Encrypted-columns list pruned.** Random-nonce AES-256-GCM produces non-deterministic ciphertext, which breaks the two equality lookups the spec itself requires: login (`SELECT ... WHERE username = ?`) and result deduplication (`SELECT ... WHERE item_id = ? AND url = ?`, spec line 565). Therefore drop encryption from these columns: + - `users.username` + - `items.url` + - `results.url` + + Keep encryption on all other columns listed in the Data Encryption table (lines 320-326): `settings.value`, `items.search_query`, `items.ntfy_topic`, `items.best_price_url`, `items.best_price_image_url`, `items.best_price_title`, `items.last_poll_error`, `results.title`, `results.image_url`, `price_history.store`. + +2. **Sessions use `github.com/alexedwards/scs/v2`** with `github.com/alexedwards/scs/sqlite3store`, not `gorilla/sessions`. `gorilla/sessions` is in maintenance mode; `scs` is actively maintained and its sqlite store fits the existing DB. The `security.session_secret` config value is still the source of the signing key. + +3. **CSRF protection is implemented as per-session synchronizer tokens plus `SameSite=Lax` session cookies.** The spec's note that POST-only logout is "CSRF-safe" (line 286) is incorrect on its own; a token system is required for all state-changing forms (item create/edit/delete, settings save, password change, user management, logout, pause/resume, run-now). Middleware validates the token on every non-idempotent method. + +4. **Vendor HTMX and Chart.js into `static/`** rather than loading from a CDN. The app is self-hosted and should not break when the host has no internet. Tailwind remains on the Play CDN for v1 (per spec line 22), with a documented path to swap for a built CSS file. Google Fonts (JetBrains Mono, Outfit) remain on CDN. + +5. **Preview results are cached in-memory for 10 minutes,** keyed on the tuple `(query, url, marketplace, listing_type, max_results)`. A re-preview within the window reuses the cached results and does not call Apify. This avoids burning Apify credits when the operator iterates on the add-item form. Cache is process-local, lost on restart. + +6. **Tests are written for these areas only:** crypto round-trip and tamper-detection; scheduler alert-condition logic; result deduplication; deal-quality badge selection. Other code is untested for v1. + +7. **Apify actor IDs are verified at build time before populating `config.toml.example`.** Each ID listed in the spec (lines 360, 402, 428, 453, 466, 502-505) is checked against `apify.com/store`. If an actor cannot be confirmed to exist with reasonable activity and success rate, its config key is left blank and the app fails fast at startup with a message naming the unset key. The Mercari JP actor was already flagged for verification by the spec itself (lines 488-489).