ammocpr/lib/ammoprices_web/live/caliber_live/show.ex
Graham McIntire 872c94bea1
Fix bugs and performance: Oban chain, 0-cent prices, duplicate queries, regex fragility
- 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)
2026-06-21 18:14:49 -05:00

629 lines
22 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,
casing_filter: nil,
grain_weight_filter: nil,
subsonic_filter: nil,
chart_range: "30d"
)
|> load_filter_options()
|> 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("filter_casing", %{"casing" => casing}, socket) do
current = socket.assigns.casing_filter
new_value = if current == casing, do: nil, else: casing
socket =
socket
|> assign(:casing_filter, new_value)
|> load_products()
{:noreply, socket}
end
@impl true
def handle_event("filter_grain_weight", %{"weight" => weight}, socket) do
current = socket.assigns.grain_weight_filter
weight_int = String.to_integer(weight)
new_value = if current == weight_int, do: nil, else: weight_int
socket =
socket
|> assign(:grain_weight_filter, new_value)
|> load_products()
{:noreply, socket}
end
@impl true
def handle_event("toggle_subsonic_filter", _params, socket) do
current = socket.assigns.subsonic_filter
new_value = if current == true, do: nil, else: true
socket =
socket
|> assign(:subsonic_filter, new_value)
|> 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, caliber_ids}, socket) do
if socket.assigns.caliber.id in caliber_ids do
socket =
socket
|> load_filter_options()
|> load_products()
|> load_chart_data()
|> load_price_stats()
{:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})}
else
{:noreply, socket}
end
end
defp load_filter_options(socket) do
caliber_id = socket.assigns.caliber.id
assign(socket,
available_casings: Catalog.distinct_casings_for_caliber(caliber_id),
available_grain_weights: Catalog.distinct_grain_weights_for_caliber(caliber_id),
has_subsonic_products: Catalog.has_subsonic_products?(caliber_id)
)
end
defp load_products(socket) do
%{
caliber: caliber,
in_stock_filter: in_stock_filter,
casing_filter: casing_filter,
grain_weight_filter: grain_weight_filter,
subsonic_filter: subsonic_filter
} = socket.assigns
opts = [limit: 200]
opts = if in_stock_filter, do: Keyword.put(opts, :in_stock, true), else: opts
opts = if casing_filter, do: Keyword.put(opts, :casing, casing_filter), else: opts
opts = if grain_weight_filter, do: Keyword.put(opts, :grain_weight, grain_weight_filter), else: opts
opts = if subsonic_filter, do: Keyword.put(opts, :subsonic, true), else: opts
snapshots = Prices.latest_prices_for_caliber(caliber.id, opts)
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)
{labels, avgs, mins} =
Enum.reduce(daily_data, {[], [], []}, fn d, {ls, as, ms} ->
{[Date.to_iso8601(d.date) | ls], [d.avg_ppr | as], [d.min_ppr | ms]}
end)
chart_data = %{
labels: Enum.reverse(labels),
avg: Enum.reverse(avgs),
min: Enum.reverse(mins)
}
assign(socket, chart_data: chart_data, chart_empty?: daily_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) 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 class="relative h-64 sm:h-80">
<div
id="price-chart-container"
phx-hook=".PriceChart"
phx-update="ignore"
data-chart-data={Jason.encode!(@chart_data)}
class="w-full h-full"
>
<canvas id="price-chart" class="w-full h-full"></canvas>
</div>
<div
:if={@chart_empty?}
id="price-chart-empty"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<p class="text-sm text-base-content/40">
No price history available for this range yet.
</p>
</div>
</div>
</div>
<%!-- Filters --%>
<div id="filter-bar" class="flex flex-wrap items-center gap-2">
<%!-- In Stock toggle --%>
<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>
<%!-- Subsonic toggle --%>
<button
:if={@has_subsonic_products}
id="filter-subsonic"
phx-click="toggle_subsonic_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(@subsonic_filter,
do: "border-warning/40 bg-warning/10 text-warning",
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(@subsonic_filter, do: "bg-warning", else: "bg-base-content/30")
]} /> Subsonic
</button>
<%!-- Casing filter buttons --%>
<%= if @available_casings != [] do %>
<span class="text-xs text-base-content/30 mx-1">|</span>
<button
:for={casing <- @available_casings}
id={"filter-casing-#{casing}"}
phx-click="filter_casing"
phx-value-casing={casing}
class={[
"px-3 py-1.5 text-xs font-semibold rounded-lg border transition-all duration-150 cursor-pointer capitalize",
if(@casing_filter == casing,
do: "border-primary/40 bg-primary/10 text-primary",
else:
"border-base-300 bg-base-100 text-base-content/60 hover:border-base-content/20"
)
]}
>
{casing}
</button>
<% end %>
<%!-- Grain weight filter buttons --%>
<%= if @available_grain_weights != [] do %>
<span class="text-xs text-base-content/30 mx-1">|</span>
<button
:for={weight <- @available_grain_weights}
id={"filter-grain-#{weight}"}
phx-click="filter_grain_weight"
phx-value-weight={to_string(weight)}
class={[
"px-3 py-1.5 text-xs font-semibold rounded-lg border transition-all duration-150 cursor-pointer",
if(@grain_weight_filter == weight,
do: "border-primary/40 bg-primary/10 text-primary",
else:
"border-base-300 bg-base-100 text-base-content/60 hover:border-base-content/20"
)
]}
>
{weight}gr
</button>
<% end %>
</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">
Grain
</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">
<div class="flex items-center gap-1.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>
<span
:if={row.product.subsonic}
class="shrink-0 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded bg-warning/15 text-warning"
>
Sub
</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<span class="sm:hidden text-xs text-base-content/50">
{row.product.retailer.name}
</span>
<span
:if={row.product.brand}
class="text-xs text-base-content/40"
>
{row.product.brand}
</span>
</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 :if={row.product.grain_weight} class="text-xs text-base-content/60">
{row.product.grain_weight}gr
</span>
<span :if={!row.product.grain_weight} class="text-xs text-base-content/30">
</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")
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
return ctx.dataset.label + ": $" + (val / 100).toFixed(2)
}
}
}
},
scales: {
x: {
type: "category",
ticks: {
maxTicksLimit: 8,
font: { size: 10 }
},
grid: { display: false }
},
y: {
ticks: {
callback: function(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