ammocpr/lib/ammoprices_web/live/caliber_live/show.ex
Graham McIntire f075ab55fb
Fix price chart destroyed by LiveView DOM patches on filter change
LiveView's morphdom was replacing the canvas element inside the chart
container whenever filters were toggled, destroying the Chart.js
rendering context. Add phx-update="ignore" to the chart container so
LiveView preserves JS-managed children after initial render.
2026-03-13 13:38:45 -05:00

614 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, _}, socket) 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})}
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)
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) 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"
phx-update="ignore"
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 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")
// 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
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