Compare commits
10 commits
fd907c40cb
...
c56bc12e88
| Author | SHA1 | Date | |
|---|---|---|---|
| c56bc12e88 | |||
| 872c94bea1 | |||
| a52a73ee09 | |||
| 66b39d8ad3 | |||
| 53b04b9090 | |||
| 2efb35dc48 | |||
| ae7e117b23 | |||
| 200b6dd9cf | |||
| 525692fe05 | |||
| f075ab55fb |
25 changed files with 593 additions and 181 deletions
|
|
@ -1,3 +1,3 @@
|
|||
erlang 28.3
|
||||
erlang 28.5
|
||||
elixir 1.19.5-otp-28
|
||||
nodejs 22.17.1
|
||||
|
|
|
|||
8
assets/vendor/chart.js
vendored
8
assets/vendor/chart.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -30,7 +30,7 @@ config :ammoprices, AmmopricesWeb.Endpoint,
|
|||
# Configure Oban
|
||||
config :ammoprices, Oban,
|
||||
repo: Ammoprices.Repo,
|
||||
queues: [scraping: 2],
|
||||
queues: [scraping: 1],
|
||||
plugins: [Oban.Plugins.Lifeline]
|
||||
|
||||
config :ammoprices,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,34 @@ defmodule Ammoprices.Catalog do
|
|||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Marks products belonging to `retailer_id`/`caliber_id` as out of stock when
|
||||
their `last_seen_at` is strictly before `cutoff`. Returns `{count, nil}`.
|
||||
|
||||
Used by the scrape runner to flag listings that disappeared from the
|
||||
retailer's current category response.
|
||||
"""
|
||||
def mark_unseen_products_out_of_stock(retailer_id, caliber_id, %DateTime{} = cutoff) do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
Product
|
||||
|> where([p], p.retailer_id == ^retailer_id)
|
||||
|> where([p], p.caliber_id == ^caliber_id)
|
||||
|> where([p], p.last_seen_at < ^cutoff)
|
||||
|> where([p], p.in_stock == true)
|
||||
|> Repo.update_all(set: [in_stock: false, updated_at: now])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Hard-deletes products whose `last_seen_at` is older than `cutoff`. Related
|
||||
price snapshots cascade via the foreign key. Returns `{count, nil}`.
|
||||
"""
|
||||
def delete_stale_products(%DateTime{} = cutoff) do
|
||||
Product
|
||||
|> where([p], p.last_seen_at < ^cutoff)
|
||||
|> Repo.delete_all()
|
||||
end
|
||||
|
||||
def list_products_for_caliber(caliber_id, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 100)
|
||||
|
||||
|
|
|
|||
|
|
@ -119,13 +119,25 @@ defmodule Ammoprices.Prices do
|
|||
Based on in-stock snapshots only.
|
||||
"""
|
||||
def price_stats_for_caliber(caliber_id) do
|
||||
thirty_days_ago = DateTime.add(DateTime.utc_now(), -30 * 86_400, :second)
|
||||
|
||||
stats =
|
||||
Repo.one(
|
||||
from(ps in PriceSnapshot,
|
||||
join: p in assoc(ps, :product),
|
||||
where: p.caliber_id == ^caliber_id,
|
||||
where: ps.in_stock == true,
|
||||
select: %{all_time_low: min(ps.price_per_round_cents), all_time_high: max(ps.price_per_round_cents)}
|
||||
select: %{
|
||||
all_time_low: min(ps.price_per_round_cents),
|
||||
all_time_high: max(ps.price_per_round_cents),
|
||||
thirty_day_avg:
|
||||
fragment(
|
||||
"round(avg(?) filter (where ? >= ?))::integer",
|
||||
ps.price_per_round_cents,
|
||||
ps.recorded_at,
|
||||
^thirty_days_ago
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -142,24 +154,11 @@ defmodule Ammoprices.Prices do
|
|||
|> select([s], min(s.price_per_round_cents))
|
||||
|> Repo.one()
|
||||
|
||||
thirty_days_ago = DateTime.add(DateTime.utc_now(), -30 * 86_400, :second)
|
||||
|
||||
thirty_day_avg =
|
||||
Repo.one(
|
||||
from(ps in PriceSnapshot,
|
||||
join: p in assoc(ps, :product),
|
||||
where: p.caliber_id == ^caliber_id,
|
||||
where: ps.in_stock == true,
|
||||
where: ps.recorded_at >= ^thirty_days_ago,
|
||||
select: fragment("round(avg(?))::integer", ps.price_per_round_cents)
|
||||
)
|
||||
)
|
||||
|
||||
%{
|
||||
current_min: current_min,
|
||||
all_time_low: stats.all_time_low,
|
||||
all_time_high: stats.all_time_high,
|
||||
thirty_day_avg: thirty_day_avg
|
||||
thirty_day_avg: stats.thirty_day_avg
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -2,20 +2,32 @@ defmodule Ammoprices.Scraping.CaliberMatcher do
|
|||
@moduledoc false
|
||||
|
||||
def match(title, calibers) do
|
||||
downcased = String.downcase(title)
|
||||
downcased_title = String.downcase(title)
|
||||
prepped = Enum.map(calibers, &prepare_caliber/1)
|
||||
|
||||
Enum.find(calibers, fn caliber ->
|
||||
name_matches?(downcased, caliber) or alias_matches?(downcased, caliber)
|
||||
end)
|
||||
case Enum.find(prepped, fn caliber ->
|
||||
name_matches?(downcased_title, caliber) or alias_matches?(downcased_title, caliber)
|
||||
end) do
|
||||
nil -> nil
|
||||
caliber -> caliber.original
|
||||
end
|
||||
end
|
||||
|
||||
defp prepare_caliber(caliber) do
|
||||
%{
|
||||
original: caliber,
|
||||
name: String.downcase(caliber.name),
|
||||
aliases: Enum.map(caliber.aliases, &String.downcase/1)
|
||||
}
|
||||
end
|
||||
|
||||
defp name_matches?(downcased_title, caliber) do
|
||||
String.contains?(downcased_title, String.downcase(caliber.name))
|
||||
String.contains?(downcased_title, caliber.name)
|
||||
end
|
||||
|
||||
defp alias_matches?(downcased_title, caliber) do
|
||||
Enum.any?(caliber.aliases, fn a ->
|
||||
String.contains?(downcased_title, String.downcase(a))
|
||||
String.contains?(downcased_title, a)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ defmodule Ammoprices.Scraping.Retailers.BulkAmmo do
|
|||
end
|
||||
|
||||
defp extract_round_count(title) do
|
||||
case Regex.run(~r/^(\d[\d,]*)\s+[Rr]ounds?/i, title) do
|
||||
case Regex.run(~r/(\d[\d,]*)\s+[Rr]ounds?/i, title) do
|
||||
[_, count] -> count |> String.replace(",", "") |> String.to_integer()
|
||||
_ -> nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -60,11 +60,13 @@ defmodule Ammoprices.Scraping.Retailers.Natchez do
|
|||
nil
|
||||
|
||||
caliber_value ->
|
||||
escaped = escape_gql_string(caliber_value)
|
||||
|
||||
"""
|
||||
{
|
||||
products(
|
||||
search: "#{caliber_value}"
|
||||
filter: { caliber: { eq: "#{caliber_value}" } }
|
||||
search: "#{escaped}"
|
||||
filter: { caliber: { eq: "#{escaped}" } }
|
||||
pageSize: 48
|
||||
currentPage: 1
|
||||
) {
|
||||
|
|
@ -80,6 +82,13 @@ defmodule Ammoprices.Scraping.Retailers.Natchez do
|
|||
end
|
||||
end
|
||||
|
||||
defp escape_gql_string(str) do
|
||||
str
|
||||
|> String.replace("\\", "\\\\")
|
||||
|> String.replace("\"", "\\\"")
|
||||
|> String.replace("\n", "\\n")
|
||||
end
|
||||
|
||||
def parse_graphql_response(%{"data" => %{"products" => %{"items" => items}}}) do
|
||||
items
|
||||
|> Enum.map(&parse_item/1)
|
||||
|
|
@ -89,7 +98,8 @@ defmodule Ammoprices.Scraping.Retailers.Natchez do
|
|||
def parse_graphql_response(_), do: []
|
||||
|
||||
defp parse_item(item) do
|
||||
price_cents = extract_price(item)
|
||||
case extract_price(item) do
|
||||
{:ok, price_cents} ->
|
||||
round_count = parse_integer(item["rounds"])
|
||||
ppr = if round_count && round_count > 0, do: round(price_cents / round_count)
|
||||
name = item["name"]
|
||||
|
|
@ -106,16 +116,21 @@ defmodule Ammoprices.Scraping.Retailers.Natchez do
|
|||
subsonic: TextDetector.detect_subsonic(name),
|
||||
in_stock: item["stock_status"] == "IN_STOCK"
|
||||
}
|
||||
|
||||
:error ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_price(%{"price_range" => %{"minimum_price" => %{"final_price" => %{"value" => value}}}}) do
|
||||
{:ok,
|
||||
value
|
||||
|> String.to_float()
|
||||
|> Kernel.*(100)
|
||||
|> round()
|
||||
|> round()}
|
||||
end
|
||||
|
||||
defp extract_price(_), do: 0
|
||||
defp extract_price(_), do: :error
|
||||
|
||||
defp parse_integer(nil), do: nil
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ defmodule Ammoprices.Scraping.Retailers.PalmettoStateArmory do
|
|||
end
|
||||
|
||||
defp parse_item(item) do
|
||||
with {:ok, title} <- extract_title(item),
|
||||
{:ok, url} <- extract_url(item),
|
||||
with {:ok, title, url} <- extract_link_info(item),
|
||||
{:ok, price_cents} <- extract_price(item),
|
||||
{:ok, ppr_cents} <- extract_unit_price(item) do
|
||||
%{
|
||||
|
|
@ -66,18 +65,13 @@ defmodule Ammoprices.Scraping.Retailers.PalmettoStateArmory do
|
|||
end
|
||||
end
|
||||
|
||||
defp extract_title(item) do
|
||||
defp extract_link_info(item) do
|
||||
case Floki.find(item, ".product-item-link") do
|
||||
[{_, _, _} = node] -> {:ok, node |> Floki.text() |> String.trim()}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
[{_, attrs, _} = node] ->
|
||||
title = node |> Floki.text() |> String.trim()
|
||||
|
||||
defp extract_url(item) do
|
||||
case Floki.find(item, ".product-item-link") do
|
||||
[{_, attrs, _}] ->
|
||||
case List.keyfind(attrs, "href", 0) do
|
||||
{"href", href} -> {:ok, href}
|
||||
{"href", href} -> {:ok, title, href}
|
||||
_ -> :error
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -161,7 +161,6 @@ defmodule Ammoprices.Scraping.Retailers.SgAmmo do
|
|||
end
|
||||
|
||||
defp extract_in_stock(row) do
|
||||
{_, _attrs, _} = row
|
||||
classes = [row] |> Floki.attribute("class") |> List.first() || ""
|
||||
String.contains?(classes, "instock")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -183,8 +183,8 @@ defmodule Ammoprices.Scraping.Retailers.TargetSportsUsa do
|
|||
defp extract_in_stock(item) do
|
||||
case Floki.find(item, ".add-to-cart") do
|
||||
[{_, _, _} = node] ->
|
||||
text = node |> Floki.text() |> String.trim()
|
||||
text == "Add To Cart"
|
||||
text = node |> Floki.text() |> String.trim() |> String.downcase()
|
||||
String.contains?(text, "add to cart")
|
||||
|
||||
_ ->
|
||||
false
|
||||
|
|
|
|||
|
|
@ -47,12 +47,8 @@ defmodule Ammoprices.Scraping.Retailers.TrueShotAmmo do
|
|||
title = product["title"]
|
||||
first_variant = List.first(product["variants"])
|
||||
|
||||
case first_variant do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
variant ->
|
||||
price_cents = parse_price(variant["price"])
|
||||
with variant when not is_nil(variant) <- first_variant,
|
||||
price_cents when is_integer(price_cents) <- parse_price(variant["price"]) do
|
||||
round_count = parse_round_count(variant["title"])
|
||||
ppr = if round_count && round_count > 0, do: round(price_cents / round_count)
|
||||
|
||||
|
|
@ -68,6 +64,8 @@ defmodule Ammoprices.Scraping.Retailers.TrueShotAmmo do
|
|||
subsonic: TextDetector.detect_subsonic(title),
|
||||
in_stock: variant["available"] == true
|
||||
}
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -78,7 +76,7 @@ defmodule Ammoprices.Scraping.Retailers.TrueShotAmmo do
|
|||
|> round()
|
||||
end
|
||||
|
||||
defp parse_price(_), do: 0
|
||||
defp parse_price(_), do: nil
|
||||
|
||||
defp parse_round_count(title) when is_binary(title) do
|
||||
case Integer.parse(title) do
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ defmodule Ammoprices.Scraping.Runner do
|
|||
alias Ammoprices.Prices
|
||||
alias Ammoprices.Scraping.HttpClient
|
||||
|
||||
require Logger
|
||||
|
||||
def run(scraper, caliber) do
|
||||
retailer = Catalog.get_retailer_by_slug!(scraper.retailer_slug())
|
||||
|
||||
|
|
@ -49,8 +51,24 @@ defmodule Ammoprices.Scraping.Runner do
|
|||
defp process_results(parsed, retailer, caliber) do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
results =
|
||||
Enum.map(parsed, fn product_data ->
|
||||
{_results, successful} =
|
||||
Enum.reduce(parsed, {[], 0}, fn product_data, {acc, count} ->
|
||||
case process_product(product_data, retailer, caliber, now) do
|
||||
:ok -> {[:ok | acc], count + 1}
|
||||
:error -> {[:error | acc], count}
|
||||
end
|
||||
end)
|
||||
|
||||
if successful > 0 do
|
||||
Catalog.mark_unseen_products_out_of_stock(retailer.id, caliber.id, now)
|
||||
end
|
||||
|
||||
Catalog.update_retailer(retailer, %{last_scraped_at: now})
|
||||
|
||||
{:ok, %{products_count: successful, snapshots_count: successful}}
|
||||
end
|
||||
|
||||
defp process_product(product_data, retailer, caliber, now) do
|
||||
product_attrs = %{
|
||||
title: product_data.title,
|
||||
url: product_data.url,
|
||||
|
|
@ -64,27 +82,26 @@ defmodule Ammoprices.Scraping.Runner do
|
|||
last_seen_at: now
|
||||
}
|
||||
|
||||
case Catalog.upsert_product(retailer.id, caliber.id, product_attrs) do
|
||||
{:ok, product} ->
|
||||
snapshot_attrs = %{
|
||||
with {:ok, product} <- Catalog.upsert_product(retailer.id, caliber.id, product_attrs),
|
||||
{:ok, _snapshot} <- create_snapshot(product, product_data, now) do
|
||||
:ok
|
||||
else
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
Logger.warning(
|
||||
"Scrape insert failed for #{retailer.slug} #{product_data.url}: " <>
|
||||
inspect(changeset.errors)
|
||||
)
|
||||
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp create_snapshot(product, product_data, now) do
|
||||
Prices.create_snapshot(product.id, %{
|
||||
price_cents: product_data.price_cents,
|
||||
price_per_round_cents: product_data.price_per_round_cents,
|
||||
in_stock: product_data.in_stock,
|
||||
recorded_at: now
|
||||
}
|
||||
|
||||
{:ok, _snapshot} = Prices.create_snapshot(product.id, snapshot_attrs)
|
||||
:ok
|
||||
|
||||
{:error, _changeset} ->
|
||||
:error
|
||||
end
|
||||
end)
|
||||
|
||||
successful = Enum.count(results, &(&1 == :ok))
|
||||
|
||||
Catalog.update_retailer(retailer, %{last_scraped_at: now})
|
||||
|
||||
{:ok, %{products_count: successful, snapshots_count: successful}}
|
||||
})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ defmodule Ammoprices.Scraping.ScrapeJob do
|
|||
@moduledoc false
|
||||
use Oban.Worker,
|
||||
queue: :scraping,
|
||||
max_attempts: 3,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :executing, :retryable]]
|
||||
max_attempts: 3
|
||||
|
||||
alias Ammoprices.Catalog
|
||||
alias Ammoprices.Scraping.Runner
|
||||
|
||||
require Logger
|
||||
|
||||
@scrapers [
|
||||
Ammoprices.Scraping.Retailers.LuckyGunner,
|
||||
Ammoprices.Scraping.Retailers.SgAmmo,
|
||||
|
|
@ -18,20 +19,45 @@ defmodule Ammoprices.Scraping.ScrapeJob do
|
|||
Ammoprices.Scraping.Retailers.Natchez
|
||||
]
|
||||
|
||||
# Products not seen for this many days are hard-deleted after each scrape cycle.
|
||||
@stale_product_days 30
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(_job) do
|
||||
calibers = Catalog.list_calibers()
|
||||
caliber_ids = Enum.map(calibers, & &1.id)
|
||||
|
||||
for scraper <- @scrapers, caliber <- calibers do
|
||||
scrape_delay()
|
||||
Runner.run(scraper, caliber)
|
||||
safe_run(scraper, caliber)
|
||||
end
|
||||
|
||||
Phoenix.PubSub.broadcast(Ammoprices.PubSub, "prices:updated", {:prices_updated, %{}})
|
||||
prune_stale_products()
|
||||
|
||||
schedule_next()
|
||||
Phoenix.PubSub.broadcast(Ammoprices.PubSub, "prices:updated", {:prices_updated, caliber_ids})
|
||||
|
||||
:ok
|
||||
after
|
||||
# Always reseed the chain, even if something above crashed. Without this,
|
||||
# a single unhandled error kills the chain until the app is restarted.
|
||||
schedule_next()
|
||||
end
|
||||
|
||||
defp safe_run(scraper, caliber) do
|
||||
Runner.run(scraper, caliber)
|
||||
rescue
|
||||
error ->
|
||||
Logger.error(
|
||||
"Scrape crashed for #{inspect(scraper)} / #{caliber.slug}: " <>
|
||||
Exception.format(:error, error, __STACKTRACE__)
|
||||
)
|
||||
|
||||
{:error, :crashed}
|
||||
end
|
||||
|
||||
defp prune_stale_products do
|
||||
cutoff = DateTime.add(DateTime.utc_now(), -@stale_product_days, :day)
|
||||
Catalog.delete_stale_products(cutoff)
|
||||
end
|
||||
|
||||
@doc false
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ defmodule Ammoprices.Scraping.TextDetector do
|
|||
cond do
|
||||
String.contains?(downcased, "steel") -> "steel"
|
||||
String.contains?(downcased, "aluminum") -> "aluminum"
|
||||
String.contains?(downcased, "nickel") -> "brass"
|
||||
String.contains?(downcased, "brass") -> "brass"
|
||||
String.contains?(downcased, "nickel") -> "brass"
|
||||
true -> nil
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@
|
|||
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
|
||||
})();
|
||||
</script>
|
||||
<script async src="https://a.w5isp.com/js/pa-SCr2Xg76FIMrHVz1QtH0_.js">
|
||||
</script>
|
||||
<script>
|
||||
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
||||
plausible.init()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{@inner_content}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,8 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:prices_updated, _}, socket) do
|
||||
def handle_info({:prices_updated, caliber_ids}, socket) do
|
||||
if socket.assigns.caliber.id in caliber_ids do
|
||||
socket =
|
||||
socket
|
||||
|> load_filter_options()
|
||||
|
|
@ -112,6 +113,9 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
|||
|> 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
|
||||
|
|
@ -178,14 +182,18 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
|||
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.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)
|
||||
labels: Enum.reverse(labels),
|
||||
avg: Enum.reverse(avgs),
|
||||
min: Enum.reverse(mins)
|
||||
}
|
||||
|
||||
assign(socket, :chart_data, chart_data)
|
||||
assign(socket, chart_data: chart_data, chart_empty?: daily_data == [])
|
||||
end
|
||||
|
||||
defp load_price_stats(socket) do
|
||||
|
|
@ -279,14 +287,26 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
|||
</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="h-64 sm:h-80"
|
||||
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 --%>
|
||||
|
|
@ -503,10 +523,6 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
|||
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ defmodule AmmopricesWeb.HomeLive do
|
|||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Ammoprices.PubSub, "prices:updated")
|
||||
end
|
||||
|
||||
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
|
||||
|
|
@ -28,12 +30,25 @@ defmodule AmmopricesWeb.HomeLive do
|
|||
end)
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
socket
|
||||
|> assign(
|
||||
page_title: "AmmoCPR",
|
||||
categories: @categories,
|
||||
calibers_by_category: grouped,
|
||||
cheapest_map: cheapest_map
|
||||
)}
|
||||
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
|
||||
|
|
|
|||
51
mix.lock
51
mix.lock
|
|
@ -1,51 +1,50 @@
|
|||
%{
|
||||
"bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"},
|
||||
"bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"},
|
||||
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"},
|
||||
"db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"},
|
||||
"decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
|
||||
"ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||
"floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"},
|
||||
"finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"},
|
||||
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
|
||||
"floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"},
|
||||
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
|
||||
"hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"},
|
||||
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
|
||||
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
"mint": {:hex, :mint, "1.9.1", "3bc120b743ed2e99ad920910f2613e9faebabb2257731b0e2ea4d8ccd9eceede", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "831101bd560b086316fab5f7adb21a4f3455717d8e4bc8368b052e09aa9163e0"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"oban": {:hex, :oban, "2.20.3", "e4d27336941955886cc7113420c32c63b70b64f10b27e08e3cf2b001153953cd", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "075ffbf1279a96bec495bc63d647b08929837d70bcc0427249ffe4d1dddaec33"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
|
||||
"oban": {:hex, :oban, "2.23.0", "1867d0fa4e8c7685217b02cc2632e3ee86c93da770e9029ff71304d9e62e53d7", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8e5f0cec5abecce78dd08cb14dc5438db90ec3884987b44773ce76fe60dd3f81"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.9", "a63ed0962ed5b903b146dab0ae8eb8387fe478f8171a5e26d56a165f35996fe1", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "3477e2dd5a4f61820341169031bdfe21275f659923bea9c5c0ea2aa1c3fcc046"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.32", "4977ad4cfab7868f3d2ba390a2cd6c62412614d48f798e85785602281a0a3827", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c9d8e76373414259ec5699a809991d1324808bee1d297730599e273282cee34"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||
"plug": {:hex, :plug, "1.20.2", "adbee2441232412e37fbb357fd5e4cd533fdd253b29f2e1992262b0f1fb01462", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b16baf55877d60891002ffc1ce0b3ff7d6f30a38a23e02e4d4293c4ac266f136"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
|
||||
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
||||
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
|
||||
"req": {:hex, :req, "0.6.2", "b9b2024f35bcf60a92cc8cad2eaaf9d4e7aace463ff74be1afe5986830184413", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cc9cd30a2ddd04989929b887178e1610c940456d962c6c3a52df6146d2eef9bf"},
|
||||
"styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"},
|
||||
"swoosh": {:hex, :swoosh, "1.23.0", "a1b7f41705357ffb06457d177e734bf378022901ce53889a68bcc59d10a23c27", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "97aaf04481ce8a351e2d15a3907778bdf3b1ea071cfff3eb8728b65943c77f6d"},
|
||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
|
||||
"swoosh": {:hex, :swoosh, "1.26.3", "9d8b60077305ce259298d9a1102e5be67cd3c41d1ea930c29e9288af195ca017", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c7683d070fe8f8aa9d174e61b01f2d527be73cd8ac40037b7109184941eb569f"},
|
||||
"tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},
|
||||
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
defmodule Ammoprices.Repo.Migrations.UpgradeObanJobsV14 do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
Oban.Migration.up(version: 14)
|
||||
end
|
||||
|
||||
def down do
|
||||
Oban.Migration.down(version: 12)
|
||||
end
|
||||
end
|
||||
|
|
@ -4,6 +4,86 @@ defmodule Ammoprices.CatalogTest do
|
|||
import Ammoprices.Fixtures
|
||||
|
||||
alias Ammoprices.Catalog
|
||||
alias Ammoprices.Catalog.Product
|
||||
alias Ammoprices.Repo
|
||||
|
||||
describe "mark_unseen_products_out_of_stock/3" do
|
||||
test "marks products in scope with last_seen_at < cutoff as out of stock" do
|
||||
retailer = retailer_fixture()
|
||||
caliber = caliber_fixture()
|
||||
cutoff = ~U[2026-04-22 12:00:00Z]
|
||||
old = ~U[2026-04-22 11:00:00Z]
|
||||
new = ~U[2026-04-22 12:30:00Z]
|
||||
|
||||
stale = product_fixture(%{retailer: retailer, caliber: caliber, last_seen_at: old, in_stock: true})
|
||||
fresh = product_fixture(%{retailer: retailer, caliber: caliber, last_seen_at: new, in_stock: true})
|
||||
|
||||
assert {count, _} = Catalog.mark_unseen_products_out_of_stock(retailer.id, caliber.id, cutoff)
|
||||
assert count == 1
|
||||
|
||||
assert Repo.get!(Product, stale.id).in_stock == false
|
||||
assert Repo.get!(Product, fresh.id).in_stock == true
|
||||
end
|
||||
|
||||
test "does not affect products from other retailers" do
|
||||
retailer = retailer_fixture()
|
||||
other_retailer = retailer_fixture(%{slug: "other"})
|
||||
caliber = caliber_fixture()
|
||||
cutoff = ~U[2026-04-22 12:00:00Z]
|
||||
old = ~U[2026-04-22 11:00:00Z]
|
||||
|
||||
other_stale =
|
||||
product_fixture(%{retailer: other_retailer, caliber: caliber, last_seen_at: old, in_stock: true})
|
||||
|
||||
Catalog.mark_unseen_products_out_of_stock(retailer.id, caliber.id, cutoff)
|
||||
|
||||
assert Repo.get!(Product, other_stale.id).in_stock == true
|
||||
end
|
||||
|
||||
test "does not affect products from other calibers" do
|
||||
retailer = retailer_fixture()
|
||||
caliber = caliber_fixture()
|
||||
other_caliber = caliber_fixture(%{slug: "other"})
|
||||
cutoff = ~U[2026-04-22 12:00:00Z]
|
||||
old = ~U[2026-04-22 11:00:00Z]
|
||||
|
||||
other_stale =
|
||||
product_fixture(%{retailer: retailer, caliber: other_caliber, last_seen_at: old, in_stock: true})
|
||||
|
||||
Catalog.mark_unseen_products_out_of_stock(retailer.id, caliber.id, cutoff)
|
||||
|
||||
assert Repo.get!(Product, other_stale.id).in_stock == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_stale_products/1" do
|
||||
test "deletes products whose last_seen_at is older than cutoff" do
|
||||
cutoff = ~U[2026-04-22 12:00:00Z]
|
||||
old = ~U[2026-03-22 00:00:00Z]
|
||||
new = ~U[2026-04-22 12:30:00Z]
|
||||
|
||||
stale = product_fixture(%{last_seen_at: old})
|
||||
fresh = product_fixture(%{last_seen_at: new})
|
||||
|
||||
assert {count, _} = Catalog.delete_stale_products(cutoff)
|
||||
assert count == 1
|
||||
|
||||
assert_raise Ecto.NoResultsError, fn -> Repo.get!(Product, stale.id) end
|
||||
assert Repo.get!(Product, fresh.id)
|
||||
end
|
||||
|
||||
test "cascades to delete related price snapshots" do
|
||||
old = ~U[2026-03-22 00:00:00Z]
|
||||
cutoff = ~U[2026-04-22 12:00:00Z]
|
||||
|
||||
stale = product_fixture(%{last_seen_at: old})
|
||||
snapshot = snapshot_fixture(%{product: stale})
|
||||
|
||||
Catalog.delete_stale_products(cutoff)
|
||||
|
||||
refute Repo.get(Ammoprices.Prices.PriceSnapshot, snapshot.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "retailers" do
|
||||
test "list_retailers/0 returns all retailers" do
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ defmodule Ammoprices.Scraping.RunnerTest do
|
|||
import Ammoprices.Fixtures
|
||||
|
||||
alias Ammoprices.Catalog
|
||||
alias Ammoprices.Catalog.Product
|
||||
alias Ammoprices.Scraping.HttpClient
|
||||
alias Ammoprices.Scraping.Retailers.LuckyGunner
|
||||
alias Ammoprices.Scraping.Runner
|
||||
|
||||
|
|
@ -13,7 +15,7 @@ defmodule Ammoprices.Scraping.RunnerTest do
|
|||
retailer = retailer_fixture(%{name: "Lucky Gunner", slug: "lucky-gunner", base_url: "https://www.luckygunner.com"})
|
||||
caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun", aliases: ["9mm", "9x19"]})
|
||||
|
||||
Req.Test.stub(Ammoprices.Scraping.HttpClient, fn conn ->
|
||||
Req.Test.stub(HttpClient, fn conn ->
|
||||
Req.Test.html(conn, @fixture_html)
|
||||
end)
|
||||
|
||||
|
|
@ -48,5 +50,112 @@ defmodule Ammoprices.Scraping.RunnerTest do
|
|||
assert {:ok, stats} = Runner.run(scraper, caliber)
|
||||
assert stats.products_count == 0
|
||||
end
|
||||
|
||||
test "marks products not seen in current scrape as out of stock",
|
||||
%{retailer: retailer, caliber: caliber} do
|
||||
# Pre-existing product that won't appear in the fixture scrape
|
||||
old = DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second)
|
||||
|
||||
stale =
|
||||
product_fixture(%{
|
||||
retailer: retailer,
|
||||
caliber: caliber,
|
||||
url: "/products/no-longer-listed",
|
||||
last_seen_at: old,
|
||||
in_stock: true
|
||||
})
|
||||
|
||||
assert {:ok, _stats} = Runner.run(LuckyGunner, caliber)
|
||||
|
||||
assert Ammoprices.Repo.get!(Product, stale.id).in_stock == false
|
||||
end
|
||||
|
||||
test "does not prune products from other retailers", %{caliber: caliber} do
|
||||
other_retailer = retailer_fixture(%{slug: "other", base_url: "https://other.com"})
|
||||
old = DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second)
|
||||
|
||||
other =
|
||||
product_fixture(%{
|
||||
retailer: other_retailer,
|
||||
caliber: caliber,
|
||||
url: "/products/other-retailer",
|
||||
last_seen_at: old,
|
||||
in_stock: true
|
||||
})
|
||||
|
||||
assert {:ok, _stats} = Runner.run(LuckyGunner, caliber)
|
||||
|
||||
assert Ammoprices.Repo.get!(Product, other.id).in_stock == true
|
||||
end
|
||||
|
||||
test "continues processing when a single product's snapshot fails validation",
|
||||
%{retailer: retailer, caliber: caliber} do
|
||||
defmodule BrokenScraper do
|
||||
@moduledoc false
|
||||
@behaviour Ammoprices.Scraping.Scraper
|
||||
|
||||
def retailer_slug, do: "lucky-gunner"
|
||||
def category_url(_), do: "/any"
|
||||
|
||||
def parse_products(_body) do
|
||||
[
|
||||
%{
|
||||
title: "Good 9mm",
|
||||
url: "/products/good",
|
||||
brand: "Good",
|
||||
grain_weight: 115,
|
||||
round_count: 50,
|
||||
casing: "brass",
|
||||
in_stock: true,
|
||||
subsonic: false,
|
||||
price_cents: 1599,
|
||||
price_per_round_cents: 32
|
||||
},
|
||||
%{
|
||||
title: "Bad 9mm (no round count)",
|
||||
url: "/products/bad",
|
||||
brand: "Bad",
|
||||
grain_weight: 115,
|
||||
round_count: nil,
|
||||
casing: "brass",
|
||||
in_stock: false,
|
||||
subsonic: false,
|
||||
price_cents: 2167,
|
||||
price_per_round_cents: nil
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
assert {:ok, stats} = Runner.run(BrokenScraper, caliber)
|
||||
assert stats.products_count == 1
|
||||
assert stats.snapshots_count == 1
|
||||
|
||||
good =
|
||||
Ammoprices.Repo.get_by!(Product, retailer_id: retailer.id, url: "/products/good")
|
||||
|
||||
assert good.in_stock == true
|
||||
end
|
||||
|
||||
test "does not prune when scrape returns zero products", %{retailer: retailer, caliber: caliber} do
|
||||
Req.Test.stub(HttpClient, fn conn ->
|
||||
Req.Test.html(conn, "<html><body>no products here</body></html>")
|
||||
end)
|
||||
|
||||
old = DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second)
|
||||
|
||||
stale =
|
||||
product_fixture(%{
|
||||
retailer: retailer,
|
||||
caliber: caliber,
|
||||
url: "/products/still-here",
|
||||
last_seen_at: old,
|
||||
in_stock: true
|
||||
})
|
||||
|
||||
assert {:ok, _stats} = Runner.run(LuckyGunner, caliber)
|
||||
|
||||
assert Ammoprices.Repo.get!(Product, stale.id).in_stock == true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ defmodule Ammoprices.Scraping.ScrapeJobTest do
|
|||
|
||||
import Ammoprices.Fixtures
|
||||
|
||||
alias Ammoprices.Scraping.HttpClient
|
||||
alias Ammoprices.Scraping.ScrapeJob
|
||||
|
||||
@fixture_html File.read!("test/fixtures/lucky_gunner/9mm.html")
|
||||
|
|
@ -44,7 +45,7 @@ defmodule Ammoprices.Scraping.ScrapeJobTest do
|
|||
|
||||
caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun", aliases: ["9mm", "9x19"]})
|
||||
|
||||
Req.Test.stub(Ammoprices.Scraping.HttpClient, fn conn ->
|
||||
Req.Test.stub(HttpClient, fn conn ->
|
||||
Req.Test.html(conn, @fixture_html)
|
||||
end)
|
||||
|
||||
|
|
@ -55,5 +56,46 @@ defmodule Ammoprices.Scraping.ScrapeJobTest do
|
|||
test "executes scrape for all enabled scrapers and calibers" do
|
||||
assert :ok = perform_job(ScrapeJob, %{})
|
||||
end
|
||||
|
||||
test "survives HttpClient crashes and still schedules the next job" do
|
||||
import Ecto.Query
|
||||
|
||||
Req.Test.stub(HttpClient, fn _conn ->
|
||||
raise "simulated network crash"
|
||||
end)
|
||||
|
||||
assert :ok = perform_job(ScrapeJob, %{})
|
||||
|
||||
scheduled_count =
|
||||
Ammoprices.Repo.aggregate(
|
||||
from(j in "oban_jobs",
|
||||
where: j.worker == "Ammoprices.Scraping.ScrapeJob" and j.state == "scheduled"
|
||||
),
|
||||
:count,
|
||||
:id
|
||||
)
|
||||
|
||||
assert scheduled_count >= 1
|
||||
end
|
||||
|
||||
test "hard-deletes products not seen in 30+ days after the scrape cycle" do
|
||||
retailer = Ammoprices.Catalog.get_retailer_by_slug!("lucky-gunner")
|
||||
caliber = Ammoprices.Catalog.get_caliber_by_slug!("9mm-luger")
|
||||
|
||||
long_ago = DateTime.utc_now() |> DateTime.add(-31, :day) |> DateTime.truncate(:second)
|
||||
|
||||
stale =
|
||||
product_fixture(%{
|
||||
retailer: retailer,
|
||||
caliber: caliber,
|
||||
url: "/products/ancient",
|
||||
last_seen_at: long_ago,
|
||||
in_stock: false
|
||||
})
|
||||
|
||||
assert :ok = perform_job(ScrapeJob, %{})
|
||||
|
||||
refute Ammoprices.Repo.get(Ammoprices.Catalog.Product, stale.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -182,6 +182,38 @@ defmodule AmmopricesWeb.CaliberLive.ShowTest do
|
|||
assert has_element?(view, "#filter-casing-steel")
|
||||
end
|
||||
|
||||
test "shows empty chart state when no data exists in range", %{conn: conn, caliber: caliber} do
|
||||
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
|
||||
|
||||
assert has_element?(view, "#price-chart-empty")
|
||||
end
|
||||
|
||||
test "hides empty chart state when data exists in range", %{conn: conn, caliber: caliber, retailer: retailer} do
|
||||
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
snapshot_fixture(%{product: product, price_per_round_cents: 28, in_stock: true, recorded_at: now})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
|
||||
|
||||
refute has_element?(view, "#price-chart-empty")
|
||||
end
|
||||
|
||||
test "chart container uses phx-update=ignore to survive DOM patches", %{
|
||||
conn: conn,
|
||||
caliber: caliber,
|
||||
retailer: retailer
|
||||
} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
product = product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115, in_stock: true})
|
||||
snapshot_fixture(%{product: product, price_per_round_cents: 28, in_stock: true, recorded_at: now})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
|
||||
|
||||
# Chart container must have phx-update="ignore" so LiveView
|
||||
# does not replace the canvas element managed by Chart.js
|
||||
assert has_element?(view, "#price-chart-container[phx-update=ignore]")
|
||||
end
|
||||
|
||||
test "receives real-time price updates via PubSub", %{conn: conn, caliber: caliber, retailer: retailer} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
|
||||
|
|
@ -196,7 +228,7 @@ defmodule AmmopricesWeb.CaliberLive.ShowTest do
|
|||
snapshot_fixture(%{product: new_product, price_per_round_cents: 18, in_stock: true, recorded_at: now})
|
||||
|
||||
# Broadcast price update
|
||||
Phoenix.PubSub.broadcast(Ammoprices.PubSub, "prices:updated", {:prices_updated, %{}})
|
||||
Phoenix.PubSub.broadcast(Ammoprices.PubSub, "prices:updated", {:prices_updated, [caliber.id]})
|
||||
|
||||
# The view should re-render with updated data
|
||||
html = render(view)
|
||||
|
|
|
|||
|
|
@ -33,5 +33,19 @@ defmodule AmmopricesWeb.HomeLiveTest do
|
|||
|
||||
assert html =~ "25"
|
||||
end
|
||||
|
||||
test "updates cheapest prices via PubSub broadcast", %{conn: conn} do
|
||||
caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun"})
|
||||
retailer = retailer_fixture()
|
||||
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
{:ok, view, _html} = live(conn, "/")
|
||||
|
||||
snapshot_fixture(%{product: product, price_per_round_cents: 17, in_stock: true, recorded_at: now})
|
||||
Phoenix.PubSub.broadcast(Ammoprices.PubSub, "prices:updated", {:prices_updated, [caliber.id]})
|
||||
|
||||
assert render(view) =~ "0.17"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue