ammocpr/lib/ammoprices/prices.ex
Graham McIntire bbe5bde145
Initial implementation of ammo price tracker
Phoenix 1.8 app that scrapes ammunition retailers (Lucky Gunner,
SGAmmo) for price data and displays historical price trends.

- Data model: retailers, calibers, products, price snapshots
- Scraper infrastructure with Req, Floki, realistic browser headers
- Oban-scheduled scrape jobs (every 4h with randomized delays)
- LiveView pages: homepage with category cards, caliber detail with
  price table, Chart.js price history, and price stats banner
- 18 seeded calibers across handgun/rifle/rimfire/shotgun categories
- 77 tests
2026-03-11 15:58:12 -05:00

172 lines
4.8 KiB
Elixir

defmodule Ammoprices.Prices do
@moduledoc false
import Ecto.Query
alias Ammoprices.Prices.PriceSnapshot
alias Ammoprices.Repo
def create_snapshot(product_id, attrs) do
%PriceSnapshot{product_id: product_id}
|> PriceSnapshot.changeset(attrs)
|> Repo.insert()
end
@doc """
Returns the most recent price snapshot per product for a caliber,
sorted by price_per_round_cents ASC.
Options:
- :in_stock - filter by in_stock status on the snapshot
- :limit - max results (default 100)
"""
def latest_prices_for_caliber(caliber_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
in_stock = Keyword.get(opts, :in_stock)
latest_per_product =
from(ps in PriceSnapshot,
join: p in assoc(ps, :product),
where: p.caliber_id == ^caliber_id,
distinct: ps.product_id,
order_by: [asc: ps.product_id, desc: ps.recorded_at]
)
query =
from(ps in subquery(latest_per_product),
order_by: [asc: ps.price_per_round_cents],
limit: ^limit
)
query =
if in_stock == nil do
query
else
where(query, [ps], ps.in_stock == ^in_stock)
end
Repo.all(query)
end
@doc """
Returns daily aggregates (avg, min, max) of price_per_round_cents
for in-stock products of a caliber.
Options:
- :days - number of days back (default 30)
"""
def daily_averages_for_caliber(caliber_id, opts \\ []) do
days = Keyword.get(opts, :days, 30)
since = DateTime.add(DateTime.utc_now(), -days * 86_400, :second)
Repo.all(
from(ps in PriceSnapshot,
join: p in assoc(ps, :product),
where: p.caliber_id == ^caliber_id,
where: ps.recorded_at >= ^since,
group_by: fragment("date_trunc('day', ?)", ps.recorded_at),
order_by: [asc: fragment("date_trunc('day', ?)", ps.recorded_at)],
select: %{
date: fragment("date_trunc('day', ?)::date", ps.recorded_at),
avg_ppr: fragment("round(avg(?))::integer", ps.price_per_round_cents),
min_ppr: min(ps.price_per_round_cents),
max_ppr: max(ps.price_per_round_cents)
}
)
)
end
@doc """
Returns all snapshots for a product within a date range.
Options:
- :days - number of days back (default 30)
"""
def price_history_for_product(product_id, opts \\ []) do
days = Keyword.get(opts, :days, 30)
since = DateTime.add(DateTime.utc_now(), -days * 86_400, :second)
Repo.all(
from(ps in PriceSnapshot,
where: ps.product_id == ^product_id,
where: ps.recorded_at >= ^since,
order_by: [asc: ps.recorded_at]
)
)
end
@doc """
Returns price statistics for a caliber: current_min, all_time_low, all_time_high.
Based on in-stock snapshots only.
"""
def price_stats_for_caliber(caliber_id) do
stats =
Repo.one(
from(ps in PriceSnapshot,
join: p in assoc(ps, :product),
where: p.caliber_id == ^caliber_id,
where: ps.in_stock == true,
select: %{all_time_low: min(ps.price_per_round_cents), all_time_high: max(ps.price_per_round_cents)}
)
)
current_min =
from(ps in PriceSnapshot,
join: p in assoc(ps, :product),
where: p.caliber_id == ^caliber_id,
where: ps.in_stock == true,
distinct: ps.product_id,
order_by: [asc: ps.product_id, desc: ps.recorded_at],
select: ps.price_per_round_cents
)
|> subquery()
|> select([s], min(s.price_per_round_cents))
|> Repo.one()
thirty_days_ago = DateTime.add(DateTime.utc_now(), -30 * 86_400, :second)
thirty_day_avg =
Repo.one(
from(ps in PriceSnapshot,
join: p in assoc(ps, :product),
where: p.caliber_id == ^caliber_id,
where: ps.in_stock == true,
where: ps.recorded_at >= ^thirty_days_ago,
select: fragment("round(avg(?))::integer", ps.price_per_round_cents)
)
)
%{
current_min: current_min,
all_time_low: stats.all_time_low,
all_time_high: stats.all_time_high,
thirty_day_avg: thirty_day_avg
}
end
@doc """
Returns the minimum price_per_round_cents for each caliber,
based on the most recent in-stock snapshots.
"""
def cheapest_per_caliber do
latest =
from(ps in PriceSnapshot,
join: p in assoc(ps, :product),
where: ps.in_stock == true,
where: p.in_stock == true,
distinct: ps.product_id,
order_by: [asc: ps.product_id, desc: ps.recorded_at],
select: %{
product_id: ps.product_id,
caliber_id: p.caliber_id,
price_per_round_cents: ps.price_per_round_cents
}
)
Repo.all(
from(s in subquery(latest),
group_by: s.caliber_id,
select: %{caliber_id: s.caliber_id, min_ppr: min(s.price_per_round_cents)}
)
)
end
end