# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview Ammoprices is a Phoenix 1.8 web application that scrapes ammunition prices from multiple online retailers, tracks price history, and displays analytics. Built with Elixir ~> 1.15, LiveView, Ecto/PostgreSQL, Tailwind CSS v4, and daisyUI. ## Common Commands ```bash mix setup # Install deps, create DB, run migrations, build assets mix phx.server # Start dev server (localhost:4000) iex -S mix phx.server # Start dev server with IEx shell mix test # Run all tests (auto-creates/migrates test DB) mix test test/path_test.exs # Run a single test file mix test test/path_test.exs:42 # Run a specific test by line number mix test --failed # Re-run previously failed tests mix precommit # Compile (warnings-as-errors) + unlock unused deps + format + credo --strict + test mix format # Format code mix ecto.gen.migration name # Generate a new migration mix ecto.migrate # Run pending migrations mix ecto.reset # Drop and recreate database ``` `mix precommit` is the required pre-commit check — always run it before finishing changes. ## Architecture ### Domain Contexts **Catalog** (`lib/ammoprices/catalog/`) — Retailers, calibers, and products. - `Retailer`: name, slug (unique), base_url, enabled flag, last_scraped_at - `Caliber`: name, slug (unique), category (handgun/rifle/rimfire/shotgun), aliases array - `Product`: belongs_to retailer & caliber. Fields: title, url (unique per retailer), brand, grain_weight, round_count, casing (brass/steel/aluminum/alloy/composite), subsonic, in_stock. Upserted on (retailer_id, url) conflict **Prices** (`lib/ammoprices/prices/`) — Immutable price snapshots and analytics. - `PriceSnapshot`: price_cents, price_per_round_cents, in_stock, recorded_at. Append-only (no updated_at) - Queries: latest prices per product (with filters), daily averages, price stats (min/max/avg), cheapest per caliber ### Scraping Pipeline (`lib/ammoprices/scraping/`) ``` ScrapeJob (Oban, every 4h) → Runner → Scraper (per retailer) → HttpClient → Floki/JSON parse ↓ Upsert products → Create snapshots → PubSub broadcast ``` - **Scraper behaviour**: Required callbacks `retailer_slug/0`, `category_url/1`, `parse_products/1`. Optional `fetch/2` for custom HTTP (e.g., GraphQL) - **Runner**: Checks `function_exported?` for `fetch/2` — uses it if available, otherwise standard HTTP GET + parse flow - **HttpClient**: Req-based with realistic Chrome headers, retries. Has `get/1` and `post_json/3` - **TextDetector**: Regex detection of subsonic and casing type from product titles - **CaliberMatcher**: Matches products to calibers via name/alias lookup - **ScrapeJob**: Oban worker, queue `:scraping` (concurrency 2), 5-15 min random delay between requests in prod, 0 in test **Retailers** (7 scrapers in `lib/ammoprices/scraping/retailers/`): | Retailer | Type | Notes | |---|---|---| | Lucky Gunner | HTML (Floki) | Slug-to-URL mapping per caliber | | SG Ammo | HTML (Floki) | Standard HTML parsing | | Target Sports USA | HTML (Floki) | Standard HTML parsing | | True Shot Ammo | JSON API (Shopify) | `/collections/{handle}/products.json`, `parse_products` accepts decoded map | | Palmetto State Armory | HTML (Magento) | `data-price-amount` attrs, brand = first word of title | | Bulk Ammo | HTML (Magento) | `a.product-name`, brand via regex from title | | Natchez | GraphQL | Uses `fetch/2` callback, `HttpClient.post_json/3`, requires `Store: default` header | ### Web Layer **Routes:** - `GET /` → `HomeLive` — Calibers grouped by category with cheapest CPR per caliber - `GET /calibers/:slug` → `CaliberLive.Show` — Price table, Chart.js chart, filter bar, stats **CaliberLive.Show filters:** in_stock (toggle), casing (mutually exclusive), grain_weight (mutually exclusive), subsonic (toggle, only shown when subsonic products exist). Chart range: 7d/30d/90d/1y/all. **Real-time:** PubSub on `"prices:updated"` topic. CaliberLive.Show subscribes on connect; broadcasts trigger reload of filter options, product stream, chart, and stats. **Components:** - `PriceComponents`: `price_per_round/1` (formats cents as $0.38), `stock_badge/1`, `retailer_link/1` - `CoreComponents`: Standard Phoenix component library (daisyUI + Tailwind) ### Assets - **Tailwind CSS v4** — `@import "tailwindcss"` syntax in `app.css`, no `tailwind.config.js` - **daisyUI** — Custom light/dark themes defined in `app.css` - **Chart.js** — Vendored in `assets/vendor/chart.js`, used via colocated `.PriceChart` hook - **esbuild** — Bundles `app.js` only. `@` alias maps to `assets/vendor/` for imports - Colocated hook imports **must** use `@/vendor/...` alias, NOT relative paths (hooks compile to `_build/`) ### Database - Binary UUIDs, UTC timestamps throughout - Products use `on_conflict: {:replace, [fields...]}` for upserts on (retailer_id, url) - Product filter indexes: (caliber_id, in_stock), (caliber_id, casing), (caliber_id, grain_weight), (caliber_id, subsonic) - Retailers and calibers seeded via database migration (not seeds.exs) ### Test Infrastructure - **Fixtures**: `test/support/fixtures.ex` — factory functions (retailer_fixture, caliber_fixture, product_fixture, snapshot_fixture) - **HTML/JSON fixtures**: `test/fixtures/{retailer_name}/` — scraped response samples for each retailer - **HTTP stubbing**: `Req.Test` with plug config in test.exs - **Oban**: `:manual` mode in test (jobs don't auto-run) - **Scrape delay**: `{0, 0}` in test config ## Key Conventions - **HTTP client**: Use `Req` (already included). Never use HTTPoison, Tesla, or `:httpc` - **Prices**: Display in dollar format ($0.38/rd), stored as integer cents internally - **LiveView templates**: Always begin with `` wrapper - **Collections**: Always use LiveView streams, never assign raw lists - **Icons**: Use `<.icon name="hero-x-mark" />` component, never Heroicons modules - **Forms**: Always use `to_form/2` assigned in LiveView, access via `@form[:field]` in templates - **JS in templates**: Use colocated hook `