ammocpr/CLAUDE.md

115 lines
6.5 KiB
Markdown

# 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 `<Layouts.app flash={@flash}>` 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 `<script :type={Phoenix.LiveView.ColocatedHook}>` with `.` prefixed names, never raw `<script>` tags
- **No inline scripts**: Import vendor deps into `app.js`/`app.css`, no external `src`/`href` in layouts
- **Tailwind classes**: Use list syntax `class={["base", condition && "extra"]}` for conditional classes
- **Never use `@apply`** in CSS