6.5 KiB
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
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_atCaliber: name, slug (unique), category (handgun/rifle/rimfire/shotgun), aliases arrayProduct: 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. Optionalfetch/2for custom HTTP (e.g., GraphQL) - Runner: Checks
function_exported?forfetch/2— uses it if available, otherwise standard HTTP GET + parse flow - HttpClient: Req-based with realistic Chrome headers, retries. Has
get/1andpost_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 caliberGET /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/1CoreComponents: Standard Phoenix component library (daisyUI + Tailwind)
Assets
- Tailwind CSS v4 —
@import "tailwindcss"syntax inapp.css, notailwind.config.js - daisyUI — Custom light/dark themes defined in
app.css - Chart.js — Vendored in
assets/vendor/chart.js, used via colocated.PriceCharthook - esbuild — Bundles
app.jsonly.@alias maps toassets/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.Testwith plug config in test.exs - Oban:
:manualmode 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/2assigned 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 externalsrc/hrefin layouts - Tailwind classes: Use list syntax
class={["base", condition && "extra"]}for conditional classes - Never use
@applyin CSS