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

88 lines
2.9 KiB
Elixir

defmodule AmmopricesWeb.HomeLive do
@moduledoc false
use AmmopricesWeb, :live_view
import AmmopricesWeb.PriceComponents
alias Ammoprices.Catalog
alias Ammoprices.Prices
@categories [
%{id: "handgun", label: "Handgun", icon: "hero-bolt"},
%{id: "rifle", label: "Rifle", icon: "hero-signal"},
%{id: "rimfire", label: "Rimfire", icon: "hero-fire"},
%{id: "shotgun", label: "Shotgun", icon: "hero-shield-check"}
]
@impl true
def mount(_params, _session, socket) do
calibers = Catalog.list_calibers()
cheapest = Prices.cheapest_per_caliber()
cheapest_map = Map.new(cheapest, fn r -> {r.caliber_id, r.min_ppr} end)
grouped =
calibers
|> Enum.group_by(& &1.category)
|> Map.new(fn {cat, cals} ->
{cat, Enum.sort_by(cals, & &1.name)}
end)
{:ok,
assign(socket,
page_title: "Ammo Price Tracker",
categories: @categories,
calibers_by_category: grouped,
cheapest_map: cheapest_map
)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="space-y-8">
<%!-- Hero --%>
<div class="text-center space-y-3">
<h1 class="text-3xl sm:text-4xl font-black tracking-tight text-base-content">
Ammo Price Tracker
</h1>
<p class="text-base-content/60 text-sm max-w-md mx-auto">
Real-time ammunition prices from top retailers. Find the cheapest rounds, track price history.
</p>
</div>
<%!-- Category grid --%>
<div class="space-y-6">
<div :for={cat <- @categories} id={"category-#{cat.id}"} class="space-y-3">
<div class="flex items-center gap-2">
<.icon name={cat.icon} class="w-5 h-5 text-primary" />
<h2 class="text-lg font-bold tracking-tight text-base-content">
{cat.label}
</h2>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
<.link
:for={cal <- Map.get(@calibers_by_category, cat.id, [])}
navigate={~p"/calibers/#{cal.slug}"}
id={"caliber-#{cal.slug}"}
class="group flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg border border-base-300 bg-base-100 hover:border-primary/40 hover:bg-primary/5 transition-all duration-150"
>
<span class="text-sm font-semibold text-base-content group-hover:text-primary transition-colors">
{cal.name}
</span>
<span
:if={Map.has_key?(@cheapest_map, cal.id)}
class="text-xs font-mono tabular-nums text-success font-semibold"
>
<.price_per_round cents={@cheapest_map[cal.id]} />
</span>
</.link>
</div>
</div>
</div>
</div>
</Layouts.app>
"""
end
end