- Fix Oban self-scheduling chain silently dying (remove unique constraint so after block can schedule next job)
- Fix Natchez and TrueShotAmmo extract_price defaulting to 0 for missing price data
- Fix caliber_matcher redundant String.downcase inside Enum.find (pre-downcase outside loop)
- Fix runner.ex double iteration (Enum.map + Enum.count merged into single Enum.reduce)
- Fix price_stats_for_caliber firing 3 queries instead of 2 (merge all_time + thirty_day via FILTER)
- Fix PSA duplicate Floki.find on same selector (merge extract_title + extract_url)
- Fix PubSub broadcast carrying no caliber IDs (now includes IDs; clients skip irrelevant reloads)
- Fix BulkAmmo ^ anchor skipping non-leading round counts
- Fix TargetSportsUSA fragile exact text match 'Add To Cart' (use case-insensitive contains)
- Fix SgAmmo dead destructure {_, _, _} = row
- Fix text_detector brass/nickel detection order
- Fix Natchez GraphQL string interpolation (add escape_gql_string)
- Fix chart data triple Enum.map merged into single reduce
- Change Oban scraping queue workers from 2 to 1 (only one needed with Process.sleep)
100 lines
3 KiB
Elixir
100 lines
3 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"},
|
|
%{id: "rifle", label: "Rifle"},
|
|
%{id: "rimfire", label: "Rimfire"},
|
|
%{id: "shotgun", label: "Shotgun"}
|
|
]
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
if connected?(socket) do
|
|
Phoenix.PubSub.subscribe(Ammoprices.PubSub, "prices:updated")
|
|
end
|
|
|
|
calibers = Catalog.list_calibers()
|
|
|
|
grouped =
|
|
calibers
|
|
|> Enum.group_by(& &1.category)
|
|
|> Map.new(fn {cat, cals} ->
|
|
{cat, Enum.sort_by(cals, & &1.name)}
|
|
end)
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(
|
|
page_title: "AmmoCPR",
|
|
categories: @categories,
|
|
calibers_by_category: grouped
|
|
)
|
|
|> assign_cheapest_map()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:prices_updated, _caliber_ids}, socket) do
|
|
{:noreply, assign_cheapest_map(socket)}
|
|
end
|
|
|
|
defp assign_cheapest_map(socket) do
|
|
cheapest_map =
|
|
Map.new(Prices.cheapest_per_caliber(), fn r -> {r.caliber_id, r.min_ppr} end)
|
|
|
|
assign(socket, :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">
|
|
AmmoCPR
|
|
</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">
|
|
<h2 class="text-lg font-bold tracking-tight text-base-content">
|
|
{cat.label}
|
|
</h2>
|
|
|
|
<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
|