115 lines
6.5 KiB
Markdown
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
|