ammocpr/lib/ammoprices_web/live/caliber_live/show.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

471 lines
16 KiB
Elixir

defmodule AmmopricesWeb.CaliberLive.Show do
@moduledoc false
use AmmopricesWeb, :live_view
import AmmopricesWeb.PriceComponents
alias Ammoprices.Catalog
alias Ammoprices.Prices
@range_days %{
"7d" => 7,
"30d" => 30,
"90d" => 90,
"1y" => 365,
"all" => 3650
}
@impl true
def mount(%{"slug" => slug}, _session, socket) do
caliber = Catalog.get_caliber_by_slug!(slug)
if connected?(socket) do
Phoenix.PubSub.subscribe(Ammoprices.PubSub, "prices:updated")
end
socket =
socket
|> assign(
page_title: caliber.name,
caliber: caliber,
in_stock_filter: true,
chart_range: "30d"
)
|> load_products()
|> load_chart_data()
|> load_price_stats()
{:ok, socket}
end
@impl true
def handle_event("toggle_stock_filter", _params, socket) do
socket =
socket
|> assign(:in_stock_filter, !socket.assigns.in_stock_filter)
|> load_products()
{:noreply, socket}
end
@impl true
def handle_event("change_range", %{"range" => range}, socket) do
socket =
socket
|> assign(:chart_range, range)
|> load_chart_data()
{:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})}
end
@impl true
def handle_info({:prices_updated, _}, socket) do
socket =
socket
|> load_products()
|> load_chart_data()
|> load_price_stats()
{:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})}
end
defp load_products(socket) do
%{caliber: caliber, in_stock_filter: in_stock_filter} = socket.assigns
opts =
if in_stock_filter do
[in_stock: true, limit: 200]
else
[limit: 200]
end
snapshots = Prices.latest_prices_for_caliber(caliber.id, opts)
# Preload product+retailer for each snapshot
product_ids = Enum.map(snapshots, & &1.product_id)
products_map =
if product_ids == [] do
%{}
else
import Ecto.Query
from(p in Catalog.Product,
where: p.id in ^product_ids,
preload: [:retailer]
)
|> Ammoprices.Repo.all()
|> Map.new(&{&1.id, &1})
end
rows =
Enum.map(snapshots, fn snap ->
product = Map.get(products_map, snap.product_id)
%{
id: snap.id,
product: product,
price_cents: snap.price_cents,
price_per_round_cents: snap.price_per_round_cents,
in_stock: snap.in_stock
}
end)
stream(socket, :products, rows, reset: true)
end
defp load_chart_data(socket) do
%{caliber: caliber, chart_range: range} = socket.assigns
days = Map.get(@range_days, range, 30)
daily_data = Prices.daily_averages_for_caliber(caliber.id, days: days)
chart_data = %{
labels: Enum.map(daily_data, &Date.to_iso8601(&1.date)),
avg: Enum.map(daily_data, & &1.avg_ppr),
min: Enum.map(daily_data, & &1.min_ppr),
max: Enum.map(daily_data, & &1.max_ppr)
}
assign(socket, :chart_data, chart_data)
end
defp load_price_stats(socket) do
stats = Prices.price_stats_for_caliber(socket.assigns.caliber.id)
assign(socket, :price_stats, stats)
end
defp format_stat_cpr(nil), do: "--"
defp format_stat_cpr(cents) when cents < 100 do
"#{cents}\u00A2"
end
defp format_stat_cpr(cents) do
"$#{:erlang.float_to_binary(cents / 100, decimals: 2)}"
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="space-y-5">
<%!-- Breadcrumb + Heading --%>
<div class="space-y-1">
<nav class="text-xs text-base-content/50">
<.link navigate={~p"/"} class="hover:text-primary transition-colors">Home</.link>
<span class="mx-1">/</span>
<span class="capitalize">{@caliber.category}</span>
</nav>
<h1
id="caliber-heading"
class="text-2xl sm:text-3xl font-black tracking-tight text-base-content"
>
{@caliber.name}
</h1>
</div>
<%!-- Price Stats Banner --%>
<div id="price-stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="rounded-lg border border-base-300 bg-base-200/30 px-4 py-3">
<div class="text-xs text-base-content/50 uppercase tracking-wider font-medium">
Current Low
</div>
<div class="text-xl font-black tabular-nums text-success mt-0.5">
{format_stat_cpr(@price_stats.current_min)}
</div>
</div>
<div class="rounded-lg border border-base-300 bg-base-200/30 px-4 py-3">
<div class="text-xs text-base-content/50 uppercase tracking-wider font-medium">
30-Day Avg
</div>
<div class="text-xl font-black tabular-nums text-base-content mt-0.5">
{format_stat_cpr(@price_stats.thirty_day_avg)}
</div>
</div>
<div class="rounded-lg border border-base-300 bg-base-200/30 px-4 py-3">
<div class="text-xs text-base-content/50 uppercase tracking-wider font-medium">
All-Time Low
</div>
<div class="text-xl font-black tabular-nums text-info mt-0.5">
{format_stat_cpr(@price_stats.all_time_low)}
</div>
</div>
<div class="rounded-lg border border-base-300 bg-base-200/30 px-4 py-3">
<div class="text-xs text-base-content/50 uppercase tracking-wider font-medium">
All-Time High
</div>
<div class="text-xl font-black tabular-nums text-error mt-0.5">
{format_stat_cpr(@price_stats.all_time_high)}
</div>
</div>
</div>
<%!-- Price Chart --%>
<div class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">
Price History
</h2>
<div class="flex gap-1">
<button
:for={range <- ~w(7d 30d 90d 1y all)}
id={"range-#{range}"}
phx-click="change_range"
phx-value-range={range}
class={[
"px-2.5 py-1 text-xs font-semibold rounded-md transition-all duration-150 cursor-pointer",
if(@chart_range == range,
do: "bg-primary text-primary-content",
else: "text-base-content/50 hover:text-base-content hover:bg-base-200"
)
]}
>
{String.upcase(range)}
</button>
</div>
</div>
<div
id="price-chart-container"
phx-hook=".PriceChart"
data-chart-data={Jason.encode!(@chart_data)}
class="h-64 sm:h-80"
>
<canvas id="price-chart" class="w-full h-full"></canvas>
</div>
</div>
<%!-- Filters --%>
<div class="flex items-center gap-3">
<button
id="filter-in-stock"
phx-click="toggle_stock_filter"
class={[
"flex items-center gap-2 px-3 py-1.5 text-xs font-semibold rounded-lg border transition-all duration-150 cursor-pointer",
if(@in_stock_filter,
do: "border-success/40 bg-success/10 text-success",
else: "border-base-300 bg-base-100 text-base-content/60 hover:border-base-content/20"
)
]}
>
<span class={[
"inline-block w-1.5 h-1.5 rounded-full",
if(@in_stock_filter, do: "bg-success", else: "bg-base-content/30")
]} /> In Stock Only
</button>
</div>
<%!-- Product Table --%>
<div class="overflow-x-auto rounded-lg border border-base-300">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-base-300 bg-base-200/50">
<th class="text-left px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider">
Product
</th>
<th class="text-left px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden sm:table-cell">
Retailer
</th>
<th class="text-right px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider">
CPR
</th>
<th class="text-right px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden sm:table-cell">
Price
</th>
<th class="text-center px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden md:table-cell">
Rounds
</th>
<th class="text-center px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden md:table-cell">
Casing
</th>
<th class="text-center px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden lg:table-cell">
Status
</th>
</tr>
</thead>
<tbody id="products" phx-update="stream">
<tr
:for={{dom_id, row} <- @streams.products}
id={dom_id}
class="border-b border-base-300/50 hover:bg-base-200/30 transition-colors"
>
<td class="px-3 py-2.5">
<a
href={row.product.url}
target="_blank"
rel="noopener noreferrer"
class="text-sm font-medium text-base-content hover:text-primary transition-colors line-clamp-2"
>
{row.product.title}
</a>
<div class="sm:hidden text-xs text-base-content/50 mt-0.5">
{row.product.retailer.name}
</div>
</td>
<td class="px-3 py-2.5 hidden sm:table-cell">
<span class="text-xs text-base-content/60">{row.product.retailer.name}</span>
</td>
<td class="px-3 py-2.5 text-right">
<.price_per_round
cents={row.price_per_round_cents}
class="text-sm font-bold text-success"
/>
</td>
<td class="px-3 py-2.5 text-right hidden sm:table-cell">
<span class="font-mono tabular-nums text-base-content/80">
{"$#{:erlang.float_to_binary(row.price_cents / 100, decimals: 2)}"}
</span>
</td>
<td class="px-3 py-2.5 text-center hidden md:table-cell">
<span class="text-xs text-base-content/60">{row.product.round_count || "—"}</span>
</td>
<td class="px-3 py-2.5 text-center hidden md:table-cell">
<span :if={row.product.casing} class="text-xs capitalize text-base-content/60">
{row.product.casing}
</span>
<span :if={!row.product.casing} class="text-xs text-base-content/30">—</span>
</td>
<td class="px-3 py-2.5 text-center hidden lg:table-cell">
<.stock_badge in_stock={row.in_stock} />
</td>
</tr>
</tbody>
</table>
<div
id="products-empty"
class="hidden only:block p-8 text-center text-base-content/40 text-sm"
>
No products found for this caliber yet. Prices will appear after the next scrape.
</div>
</div>
<%!-- Colocated Chart.js Hook --%>
<script :type={Phoenix.LiveView.ColocatedHook} name=".PriceChart">
import Chart from "@/vendor/chart.js"
export default {
mounted() {
const data = JSON.parse(this.el.dataset.chartData)
this.chart = this.createChart(data)
this.handleEvent("update-chart", ({data}) => {
this.updateChart(data)
})
},
createChart(data) {
const canvas = this.el.querySelector("canvas")
if (!canvas) return null
const ctx = canvas.getContext("2d")
// Get CSS custom property values for theming
const style = getComputedStyle(document.documentElement)
const textColor = style.getPropertyValue("color") || "#666"
return new Chart(ctx, {
type: "line",
data: {
labels: data.labels,
datasets: [
{
label: "Avg CPR",
data: data.avg,
borderColor: "oklch(0.65 0.19 160)",
backgroundColor: "oklch(0.65 0.19 160 / 0.1)",
borderWidth: 2,
fill: false,
tension: 0.3,
pointRadius: data.labels.length > 60 ? 0 : 3,
pointHoverRadius: 5
},
{
label: "Min CPR",
data: data.min,
borderColor: "oklch(0.7 0.15 200)",
backgroundColor: "oklch(0.7 0.15 200 / 0.05)",
borderWidth: 1.5,
borderDash: [4, 4],
fill: false,
tension: 0.3,
pointRadius: 0,
pointHoverRadius: 4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false
},
plugins: {
legend: {
display: true,
position: "top",
labels: {
boxWidth: 12,
padding: 16,
usePointStyle: true,
font: { size: 11 }
}
},
tooltip: {
callbacks: {
label: function(ctx) {
const val = ctx.raw
if (val < 100) return ctx.dataset.label + ": " + val + "¢/rd"
return ctx.dataset.label + ": $" + (val / 100).toFixed(2) + "/rd"
}
}
}
},
scales: {
x: {
type: "category",
ticks: {
maxTicksLimit: 8,
font: { size: 10 }
},
grid: { display: false }
},
y: {
ticks: {
callback: function(val) {
if (val < 100) return val + "¢"
return "$" + (val / 100).toFixed(2)
},
font: { size: 10 }
},
grid: { color: "oklch(0.5 0 0 / 0.08)" }
}
}
}
})
},
updateChart(data) {
if (!this.chart) {
this.chart = this.createChart(data)
return
}
this.chart.data.labels = data.labels
this.chart.data.datasets[0].data = data.avg
this.chart.data.datasets[0].pointRadius = data.labels.length > 60 ? 0 : 3
this.chart.data.datasets[1].data = data.min
this.chart.update()
},
destroyed() {
if (this.chart) {
this.chart.destroy()
}
}
}
</script>
</div>
</Layouts.app>
"""
end
end