Initial implementation of ammo price tracker

Phoenix 1.8 app that scrapes ammunition retailers (Lucky Gunner,
SGAmmo) for price data and displays historical price trends.

- Data model: retailers, calibers, products, price snapshots
- Scraper infrastructure with Req, Floki, realistic browser headers
- Oban-scheduled scrape jobs (every 4h with randomized delays)
- LiveView pages: homepage with category cards, caliber detail with
  price table, Chart.js price history, and price stats banner
- 18 seeded calibers across handgun/rifle/rimfire/shotgun categories
- 77 tests
This commit is contained in:
Graham McIntire 2026-03-11 15:55:47 -05:00
commit bbe5bde145
No known key found for this signature in database
83 changed files with 7168 additions and 0 deletions

6
.formatter.exs Normal file
View file

@ -0,0 +1,6 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
plugins: [Styler, Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]

37
.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Temporary files, for example, from tests.
/tmp/
# Ignore package tarball (built via "mix hex.build").
ammoprices-*.tar
# Ignore assets that are produced by build tools.
/priv/static/assets/
# Ignore digested assets cache.
/priv/static/cache_manifest.json
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/

449
AGENTS.md Normal file
View file

@ -0,0 +1,449 @@
This is a web application written using the Phoenix web framework.
## Project guidelines
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
### Phoenix v1.8 guidelines
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
- Anytime you run into errors with no `current_scope` assign:
- You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
- **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
custom classes must fully style the input
### JS and CSS guidelines
- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces.
- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`:
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/my_app_web";
- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new`
- **Never** use `@apply` when writing raw css
- **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design
- Out of the box **only the app.js and app.css bundles are supported**
- You cannot reference an external vendor'd script `src` or link `href` in the layouts
- You must import the vendor deps into app.js and app.css to use them
- **Never write inline <script>custom js</script> tags within templates**
### UI/UX & design guidelines
- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles
- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions)
- Ensure **clean typography, spacing, and layout balance** for a refined, premium look
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
## Elixir guidelines
- Elixir lists **do not support index based access via the access syntax**
**Never do this (invalid)**:
i = 0
mylist = ["blue", "green"]
mylist[i]
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
i = 0
mylist = ["blue", "green"]
Enum.at(mylist, i)
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
# INVALID: we are rebinding inside the `if` and the result never gets assigned
if connected?(socket) do
socket = assign(socket, :val, val)
end
# VALID: we rebind the result of the `if` to a new variable
socket =
if connected?(socket) do
assign(socket, :val, val)
end
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
- Don't use `String.to_atom/1` on user input (memory leak risk)
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
## Mix guidelines
- Read the docs and options before using tasks (by using `mix help task_name`)
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
## Test guidelines
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
<!-- phoenix:elixir-end -->
<!-- phoenix:phoenix-start -->
## Phoenix guidelines
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
scope "/admin", AppWeb.Admin do
pipe_through :browser
live "/users", UserLive, :index
end
the UserLive route would point to the `AppWeb.Admin.UserLive` module
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
<!-- phoenix:phoenix-end -->
<!-- phoenix:ecto-start -->
## Ecto Guidelines
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
<!-- phoenix:ecto-end -->
<!-- phoenix:html-start -->
## Phoenix HTML guidelines
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
**Never do this (invalid)**:
<%= if condition do %>
...
<% else if other_condition %>
...
<% end %>
Instead **always** do this:
<%= cond do %>
<% condition -> %>
...
<% condition2 -> %>
...
<% true -> %>
...
<% end %>
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
<code phx-no-curly-interpolation>
let obj = {key: "val"}
</code>
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
<a class={[
"px-2 text-white",
@some_flag && "py-5",
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
...
]}>Text</a>
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
and **never** do this, since it's invalid (note the missing `[` and `]`):
<a class={
"px-2 text-white",
@some_flag && "py-5"
}> ...
=> Raises compile syntax error on invalid HEEx attr syntax
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
**Always** do this:
<div id={@id}>
{@my_assign}
<%= if @some_block_condition do %>
{@another_assign}
<% end %>
</div>
and **Never** do this the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
<div id="<%= @invalid_interpolation %>">
{if @invalid_block_construct do}
{end}
</div>
<!-- phoenix:html-end -->
<!-- phoenix:liveview-start -->
## Phoenix LiveView guidelines
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
- **Avoid LiveComponent's** unless you have a strong, specific need for them
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
### LiveView streams
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items - `stream(socket, :messages, [new_msg])`
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
- deleting items - `stream_delete(socket, :messages, msg)`
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
def handle_event("filter", %{"filter" => filter}, socket) do
# re-fetch the messages based on the filter
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
# reset the stream with the new messages
|> stream(:messages, messages, reset: true)}
end
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div>
<div :for={{id, task} <- @stream.tasks} id={id}>
{task.name}
</div>
</div>
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
along with the updated assign:
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
message = Chat.get_message!(message_id)
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
# re-insert message so @editing_message_id toggle logic takes effect for that stream item
{:noreply,
socket
|> stream_insert(:messages, message)
|> assign(:editing_message_id, String.to_integer(message_id))
|> assign(:edit_form, edit_form)}
end
And in the template:
<div id="messages" phx-update="stream">
<div :for={{id, message} <- @streams.messages} id={id} class="flex group">
{message.username}
<%= if @editing_message_id == message.id do %>
<%!-- Edit mode --%>
<.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
...
</.form>
<% end %>
</div>
</div>
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
### LiveView JavaScript interop
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
#### Inline colocated js hooks
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
when writing scripts inside the template**:
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
export default {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
</script>
- colocated hooks are automatically integrated into the app.js bundle
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
#### External phx-hook
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
LiveSocket constructor:
const MyHook = {
mounted() { ... }
}
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { MyHook }
});
#### Pushing events between client and server
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
**Always** return or rebind the socket on `push_event/3` when pushing events:
# re-bind socket so we maintain event state to be pushed
socket = push_event(socket, "my_event", %{...})
# or return the modified socket directly:
def handle_event("some_event", _, socket) do
{:noreply, push_event(socket, "my_event", %{...})}
end
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
mounted() {
this.handleEvent("my_event", data => console.log("from server:", data));
}
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
mounted() {
this.el.addEventListener("click", e => {
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
})
}
Where the server handled it via:
def handle_event("my_event", %{"one" => 1}, socket) do
{:reply, %{two: 2}, socket}
end
### LiveView tests
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
- Focus on testing outcomes rather than implementation details
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")
### Form handling
#### Creating a form from params
If you want to create a form based on `handle_event` params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
#### Creating a form from changesets
When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to `to_form`:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
In the template, the form form assign can be passed to the `<.form>` function component:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
#### Avoiding form errors
**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And **never** do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
<!-- phoenix:liveview-end -->
<!-- usage-rules-end -->

73
CLAUDE.md Normal file
View file

@ -0,0 +1,73 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Ammoprices is a Phoenix 1.8 web application (Elixir ~> 1.15) with LiveView, Ecto/PostgreSQL, and Tailwind CSS v4 with daisyUI. Currently a fresh scaffold with no domain logic implemented yet.
## Common Commands
```bash
mix setup # Install deps, create DB, run migrations, build assets
mix phx.server # Start dev server (localhost:4000)
iex -S mix phx.server # Start dev server with IEx shell
mix test # Run all tests (auto-creates/migrates test DB)
mix test test/path_test.exs # Run a single test file
mix test test/path_test.exs:42 # Run a specific test by line number
mix test --failed # Re-run previously failed tests
mix precommit # Compile (warnings-as-errors) + unlock unused deps + format + test
mix format # Format code
mix ecto.gen.migration name # Generate a new migration
mix ecto.migrate # Run pending migrations
mix ecto.reset # Drop and recreate database
```
`mix precommit` is the required pre-commit check — always run it before finishing changes.
## Architecture
### Application Structure
- **`Ammoprices.Application`** — Supervision tree: Telemetry, Repo, DNSCluster, PubSub, Endpoint
- **`Ammoprices.Repo`** — Ecto repo using PostgreSQL adapter
- **`Ammoprices.Mailer`** — Swoosh mailer (local adapter in dev, viewable at `/dev/mailbox`)
### Web Layer (`lib/ammoprices_web/`)
- **`AmmopricesWeb`** (`ammoprices_web.ex`) — Defines `use` macros for `:router`, `:controller`, `:live_view`, `:live_component`, `:html`. The `html_helpers/0` block imports `CoreComponents`, aliases `Layouts` and `Phoenix.LiveView.JS`, and sets up verified routes
- **`AmmopricesWeb.Router`** — Browser pipeline only. Currently just `GET / → PageController.home`
- **`AmmopricesWeb.CoreComponents`** — UI component library using daisyUI + Tailwind. Includes `flash/1`, `button/1`, `icon/1`, standard form/table components
- **`AmmopricesWeb.Layouts`** — `app/1` layout with navbar and theme toggle (light/dark/system). Templates wrap content with `<Layouts.app flash={@flash}>`
### Config
- **`config/config.exs`** — Generator defaults: UTC timestamps, binary IDs
- **`config/dev.exs`** — DB: `ammoprices_dev`, live reload watchers for esbuild/tailwind
- **`config/test.exs`** — DB: `ammoprices_test`, SQL sandbox mode
### Assets (`assets/`)
- **Tailwind CSS v4** with `@import "tailwindcss"` syntax (no `tailwind.config.js`)
- **daisyUI** plugin with custom light/dark themes defined in `app.css`
- **esbuild** bundles `app.js` — only `app.js` and `app.css` bundles are supported
- Vendor libs: `topbar.js`, `heroicons.js`, `daisyui.js`, `daisyui-theme.js`
- JS hooks for LiveView use colocated hook syntax (names prefixed with `.`)
### Test Support (`test/support/`)
- **`ConnCase`** — For controller/LiveView tests, sets up conn and DB sandbox
- **`DataCase`** — For context/schema tests, provides `errors_on/1` helper
- **`LazyHTML`** — Available in tests for HTML assertion selectors
## Key Conventions
- **HTTP client**: Use `Req` (already included). Never use HTTPoison, Tesla, or `:httpc`
- **LiveView templates**: Always begin with `<Layouts.app flash={@flash}>` wrapper
- **Icons**: Use `<.icon name="hero-x-mark" />` component, never Heroicons modules
- **Forms**: Always use `to_form/2` assigned in LiveView, access via `@form[:field]` in templates
- **Collections**: Always use LiveView streams, never assign raw lists
- **JS in templates**: Use colocated hook `<script :type={Phoenix.LiveView.ColocatedHook}>` with `.` prefixed names, never raw `<script>` tags
- **No inline scripts**: Import vendor deps into `app.js`/`app.css`, no external `src`/`href` in layouts
- **Tailwind classes**: Use list syntax `class={["base", condition && "extra"]}` for conditional classes
- **Never use `@apply`** in CSS

18
README.md Normal file
View file

@ -0,0 +1,18 @@
# Ammoprices
To start your Phoenix server:
* Run `mix setup` to install and setup dependencies
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
## Learn more
* Official website: https://www.phoenixframework.org/
* Guides: https://hexdocs.pm/phoenix/overview.html
* Docs: https://hexdocs.pm/phoenix
* Forum: https://elixirforum.com/c/phoenix-forum
* Source: https://github.com/phoenixframework/phoenix

105
assets/css/app.css Normal file
View file

@ -0,0 +1,105 @@
/* See the Tailwind configuration guide for advanced usage
https://tailwindcss.com/docs/configuration */
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/ammoprices_web";
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
The heroicons installation itself is managed by your mix.exs */
@plugin "../vendor/heroicons";
/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with:
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
Make sure to look at the daisyUI changelog: https://daisyui.com/docs/changelog/ */
@plugin "../vendor/daisyui" {
themes: false;
}
/* daisyUI theme plugin. You can update this file by fetching the latest version with:
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
@plugin "../vendor/daisyui-theme" {
name: "dark";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(30.33% 0.016 252.42);
--color-base-200: oklch(25.26% 0.014 253.1);
--color-base-300: oklch(20.15% 0.012 254.09);
--color-base-content: oklch(97.807% 0.029 256.847);
--color-primary: oklch(58% 0.233 277.117);
--color-primary-content: oklch(96% 0.018 272.314);
--color-secondary: oklch(58% 0.233 277.117);
--color-secondary-content: oklch(96% 0.018 272.314);
--color-accent: oklch(60% 0.25 292.717);
--color-accent-content: oklch(96% 0.016 293.756);
--color-neutral: oklch(37% 0.044 257.287);
--color-neutral-content: oklch(98% 0.003 247.858);
--color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(97% 0.013 236.62);
--color-success: oklch(60% 0.118 184.704);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
@plugin "../vendor/daisyui-theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0 0);
--color-base-200: oklch(96% 0.001 286.375);
--color-base-300: oklch(92% 0.004 286.32);
--color-base-content: oklch(21% 0.006 285.885);
--color-primary: oklch(70% 0.213 47.604);
--color-primary-content: oklch(98% 0.016 73.684);
--color-secondary: oklch(55% 0.027 264.364);
--color-secondary-content: oklch(98% 0.002 247.839);
--color-accent: oklch(0% 0 0);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(44% 0.017 285.786);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(62% 0.214 259.815);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(70% 0.14 182.503);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
/* Add variants based on LiveView classes */
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
/* Use the data attribute for dark mode */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents }
/* This file is for your main application CSS */

83
assets/js/app.js Normal file
View file

@ -0,0 +1,83 @@
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
// To load it, simply add a second `<link>` to your `root.html.heex` file.
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/ammoprices"
import topbar from "../vendor/topbar"
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks},
})
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
// The lines below enable quality of life phoenix_live_reload
// development features:
//
// 1. stream server logs to the browser console
// 2. click on elements to jump to their definitions in your code editor
//
if (process.env.NODE_ENV === "development") {
window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
// Enable server log streaming to client.
// Disable with reloader.disableServerLogs()
reloader.enableServerLogs()
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
//
// * click with "c" key pressed to open at caller location
// * click with "d" key pressed to open at function component definition location
let keyDown
window.addEventListener("keydown", e => keyDown = e.key)
window.addEventListener("keyup", _e => keyDown = null)
window.addEventListener("click", e => {
if(keyDown === "c"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtCaller(e.target)
} else if(keyDown === "d"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtDef(e.target)
}
}, true)
window.liveReloader = reloader
})
}

32
assets/tsconfig.json Normal file
View file

@ -0,0 +1,32 @@
// This file is needed on most editors to enable the intelligent autocompletion
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
//
// Note: This file assumes a basic esbuild setup without node_modules.
// We include a generic paths alias to deps to mimic how esbuild resolves
// the Phoenix and LiveView JavaScript assets.
// If you have a package.json in your project, you should remove the
// paths configuration and instead add the phoenix dependencies to the
// dependencies section of your package.json:
//
// {
// ...
// "dependencies": {
// ...,
// "phoenix": "../deps/phoenix",
// "phoenix_html": "../deps/phoenix_html",
// "phoenix_live_view": "../deps/phoenix_live_view"
// }
// }
//
// Feel free to adjust this configuration however you need.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": ["../deps/*"]
},
"allowJs": true,
"noEmit": true
},
"include": ["js/**/*"]
}

14
assets/vendor/chart.js vendored Normal file

File diff suppressed because one or more lines are too long

124
assets/vendor/daisyui-theme.js vendored Normal file

File diff suppressed because one or more lines are too long

1031
assets/vendor/daisyui.js vendored Normal file

File diff suppressed because one or more lines are too long

43
assets/vendor/heroicons.js vendored Normal file
View file

@ -0,0 +1,43 @@
const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")
module.exports = plugin(function({matchComponents, theme}) {
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized")
let values = {}
let icons = [
["", "/24/outline"],
["-solid", "/24/solid"],
["-mini", "/20/solid"],
["-micro", "/16/solid"]
]
icons.forEach(([suffix, dir]) => {
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
let name = path.basename(file, ".svg") + suffix
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
})
})
matchComponents({
"hero": ({name, fullPath}) => {
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
content = encodeURIComponent(content)
let size = theme("spacing.6")
if (name.endsWith("-mini")) {
size = theme("spacing.5")
} else if (name.endsWith("-micro")) {
size = theme("spacing.4")
}
return {
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
"-webkit-mask": `var(--hero-${name})`,
"mask": `var(--hero-${name})`,
"mask-repeat": "no-repeat",
"background-color": "currentColor",
"vertical-align": "middle",
"display": "inline-block",
"width": size,
"height": size
}
}
}, {values})
})

138
assets/vendor/topbar.js vendored Normal file
View file

@ -0,0 +1,138 @@
/**
* @license MIT
* topbar 3.0.0
* http://buunguyen.github.io/topbar
* Copyright (c) 2024 Buu Nguyen
*/
(function (window, document) {
"use strict";
var canvas,
currentProgress,
showing,
progressTimerId = null,
fadeTimerId = null,
delayTimerId = null,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
else elem["on" + type] = handler;
},
options = {
autoRun: true,
barThickness: 3,
barColors: {
0: "rgba(26, 188, 156, .9)",
".25": "rgba(52, 152, 219, .9)",
".50": "rgba(241, 196, 15, .9)",
".75": "rgba(230, 126, 34, .9)",
"1.0": "rgba(211, 84, 0, .9)",
},
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, .6)",
className: null,
},
repaint = function () {
canvas.width = window.innerWidth;
canvas.height = options.barThickness * 5; // need space for shadow
var ctx = canvas.getContext("2d");
ctx.shadowBlur = options.shadowBlur;
ctx.shadowColor = options.shadowColor;
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
for (var stop in options.barColors)
lineGradient.addColorStop(stop, options.barColors[stop]);
ctx.lineWidth = options.barThickness;
ctx.beginPath();
ctx.moveTo(0, options.barThickness / 2);
ctx.lineTo(
Math.ceil(currentProgress * canvas.width),
options.barThickness / 2
);
ctx.strokeStyle = lineGradient;
ctx.stroke();
},
createCanvas = function () {
canvas = document.createElement("canvas");
var style = canvas.style;
style.position = "fixed";
style.top = style.left = style.right = style.margin = style.padding = 0;
style.zIndex = 100001;
style.display = "none";
if (options.className) canvas.classList.add(options.className);
addEvent(window, "resize", repaint);
},
topbar = {
config: function (opts) {
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
show: function (delay) {
if (showing) return;
if (delay) {
if (delayTimerId) return;
delayTimerId = setTimeout(() => topbar.show(), delay);
} else {
showing = true;
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
if (!canvas) createCanvas();
if (!canvas.parentElement) document.body.appendChild(canvas);
canvas.style.opacity = 1;
canvas.style.display = "block";
topbar.progress(0);
if (options.autoRun) {
(function loop() {
progressTimerId = window.requestAnimationFrame(loop);
topbar.progress(
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
);
})();
}
}
},
progress: function (to) {
if (typeof to === "undefined") return currentProgress;
if (typeof to === "string") {
to =
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
? currentProgress
: 0) + parseFloat(to);
}
currentProgress = to > 1 ? 1 : to;
repaint();
return currentProgress;
},
hide: function () {
clearTimeout(delayTimerId);
delayTimerId = null;
if (!showing) return;
showing = false;
if (progressTimerId != null) {
window.cancelAnimationFrame(progressTimerId);
progressTimerId = null;
}
(function loop() {
if (topbar.progress("+.1") >= 1) {
canvas.style.opacity -= 0.05;
if (canvas.style.opacity <= 0.05) {
canvas.style.display = "none";
fadeTimerId = null;
return;
}
}
fadeTimerId = window.requestAnimationFrame(loop);
})();
},
};
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = topbar;
} else if (typeof define === "function" && define.amd) {
define(function () {
return topbar;
});
} else {
this.topbar = topbar;
}
}.call(this, window, document));

77
config/config.exs Normal file
View file

@ -0,0 +1,77 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
# Configure the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :ammoprices, Ammoprices.Mailer, adapter: Swoosh.Adapters.Local
# Configure the endpoint
config :ammoprices, AmmopricesWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: AmmopricesWeb.ErrorHTML, json: AmmopricesWeb.ErrorJSON],
layout: false
],
pubsub_server: Ammoprices.PubSub,
live_view: [signing_salt: "eW83llIV"]
# Configure Oban
config :ammoprices, Oban,
repo: Ammoprices.Repo,
queues: [scraping: 2],
plugins: [
{Oban.Plugins.Cron,
crontab: [
{"0 */4 * * *", Ammoprices.Scraping.ScrapeJob}
]}
]
config :ammoprices,
ecto_repos: [Ammoprices.Repo],
generators: [timestamp_type: :utc_datetime, binary_id: true]
# Configure esbuild (the version is required)
config :esbuild,
version: "0.25.4",
ammoprices: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
]
# Configure Elixir's Logger
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# Configure tailwind (the version is required)
config :tailwind,
version: "4.1.12",
ammoprices: [
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/css/app.css
),
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
cd: Path.expand("..", __DIR__)
]
import_config "#{config_env()}.exs"

92
config/dev.exs Normal file
View file

@ -0,0 +1,92 @@
import Config
# Configure your database
config :ammoprices, Ammoprices.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "ammoprices_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it
# to bundle .js and .css sources.
config :ammoprices, AmmopricesWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "KxHEUU3KodIRdZgZQMSqLWT08Iupwri91KuVlEj0wuTGUfZcUsdutCpr4Koab9Jm",
watchers: [
esbuild: {Esbuild, :install_and_run, [:ammoprices, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:ammoprices, ~w(--watch)]}
]
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
#
# mix phx.gen.cert
#
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
#
# https: [
# port: 4001,
# cipher_suite: :strong,
# keyfile: "priv/cert/selfsigned_key.pem",
# certfile: "priv/cert/selfsigned.pem"
# ],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.
# Reload browser tabs when matching files change.
config :ammoprices, AmmopricesWeb.Endpoint,
live_reload: [
web_console_logger: true,
patterns: [
# Static assets, except user uploads
~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$",
# Gettext translations
~r"priv/gettext/.*\.po$",
# Router, Controllers, LiveViews and LiveComponents
~r"lib/ammoprices_web/router\.ex$",
~r"lib/ammoprices_web/(controllers|live|components)/.*\.(ex|heex)$"
]
]
# Enable dev routes for dashboard and mailbox
config :ammoprices, dev_routes: true
# Do not include metadata nor timestamps in development logs
config :logger, :default_formatter, format: "[$level] $message\n"
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
config :phoenix_live_view,
# Include debug annotations and locations in rendered markup.
# Changing this configuration will require mix clean and a full recompile.
debug_heex_annotations: true,
debug_attributes: true,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false

30
config/prod.exs Normal file
View file

@ -0,0 +1,30 @@
import Config
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the `mix assets.deploy` task,
# which you should run after static files are built and
# before starting your production server.
config :ammoprices, AmmopricesWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
# Force using SSL in production. This also sets the "strict-security-transport" header,
# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
# Note `:force_ssl` is required to be set at compile-time.
config :ammoprices, AmmopricesWeb.Endpoint,
force_ssl: [rewrite_on: [:x_forwarded_proto]],
exclude: [
# paths: ["/health"],
hosts: ["localhost", "127.0.0.1"]
]
# Do not print debug messages in production
config :logger, level: :info
# Configure Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Req
# Runtime production configuration, including reading
# Disable Swoosh Local Memory Storage
# of environment variables, is done on config/runtime.exs.
config :swoosh, local: false

119
config/runtime.exs Normal file
View file

@ -0,0 +1,119 @@
import Config
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/ammoprices start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :ammoprices, AmmopricesWeb.Endpoint, server: true
end
config :ammoprices, AmmopricesWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))]
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "example.com"
config :ammoprices, Ammoprices.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
# For machines with several cores, consider starting multiple pools of `pool_size`
# pool_count: 4,
socket_options: maybe_ipv6
config :ammoprices, AmmopricesWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0}
],
secret_key_base: secret_key_base
config :ammoprices, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :ammoprices, AmmopricesWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :ammoprices, AmmopricesWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Here is an example configuration for Mailgun:
#
# config :ammoprices, Ammoprices.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
# and Finch out-of-the-box. This configuration is typically done at
# compile-time in your config/prod.exs:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Req
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end

50
config/test.exs Normal file
View file

@ -0,0 +1,50 @@
import Config
# In test we don't send emails
config :ammoprices, Ammoprices.Mailer, adapter: Swoosh.Adapters.Test
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :ammoprices, Ammoprices.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "ammoprices_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :ammoprices, AmmopricesWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "3Dqelm7MG3lTMnfjThpCeE0sPMEC5FBlKmOuCfZ5X/ZHiYqW9fxBif4fc9Db9B0j",
server: false
# Oban testing mode
config :ammoprices, Oban, testing: :manual
# Stub HTTP requests in tests
config :ammoprices, :req_options, plug: {Req.Test, Ammoprices.Scraping.HttpClient}
# No delay between scrape requests in tests
config :ammoprices, :scrape_delay_ms, {0, 0}
# Print only warnings and errors during test
config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
# Sort query params output of verified routes for robust url comparisons
config :phoenix,
sort_verified_routes_query_params: true
# Enable helpful, but potentially expensive runtime checks
config :phoenix_live_view,
enable_expensive_runtime_checks: true
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false

9
lib/ammoprices.ex Normal file
View file

@ -0,0 +1,9 @@
defmodule Ammoprices do
@moduledoc """
Ammoprices keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end

View file

@ -0,0 +1,32 @@
defmodule Ammoprices.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
AmmopricesWeb.Telemetry,
Ammoprices.Repo,
{DNSCluster, query: Application.get_env(:ammoprices, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Ammoprices.PubSub},
{Oban, Application.fetch_env!(:ammoprices, Oban)},
AmmopricesWeb.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Ammoprices.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
AmmopricesWeb.Endpoint.config_change(changed, removed)
:ok
end
end

84
lib/ammoprices/catalog.ex Normal file
View file

@ -0,0 +1,84 @@
defmodule Ammoprices.Catalog do
@moduledoc false
import Ecto.Query
alias Ammoprices.Catalog.Caliber
alias Ammoprices.Catalog.Product
alias Ammoprices.Catalog.Retailer
alias Ammoprices.Repo
# Retailers
def list_retailers do
Repo.all(Retailer)
end
def get_retailer!(id) do
Repo.get!(Retailer, id)
end
def get_retailer_by_slug!(slug) do
Repo.get_by!(Retailer, slug: slug)
end
def create_retailer(attrs) do
%Retailer{}
|> Retailer.changeset(attrs)
|> Repo.insert()
end
def update_retailer(%Retailer{} = retailer, attrs) do
retailer
|> Retailer.changeset(attrs)
|> Repo.update()
end
# Calibers
def list_calibers do
Repo.all(Caliber)
end
def list_calibers_by_category(category) do
Caliber
|> where([c], c.category == ^category)
|> Repo.all()
end
def get_caliber!(id) do
Repo.get!(Caliber, id)
end
def get_caliber_by_slug!(slug) do
Repo.get_by!(Caliber, slug: slug)
end
def create_caliber(attrs) do
%Caliber{}
|> Caliber.changeset(attrs)
|> Repo.insert()
end
# Products
def upsert_product(retailer_id, caliber_id, attrs) do
%Product{retailer_id: retailer_id, caliber_id: caliber_id}
|> Product.changeset(attrs)
|> Repo.insert(
on_conflict:
{:replace,
[:title, :brand, :grain_weight, :round_count, :casing, :condition, :in_stock, :last_seen_at, :updated_at]},
conflict_target: [:retailer_id, :url],
returning: true
)
end
def list_products_for_caliber(caliber_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
Product
|> where([p], p.caliber_id == ^caliber_id)
|> limit(^limit)
|> Repo.all()
end
end

View file

@ -0,0 +1,27 @@
defmodule Ammoprices.Catalog.Caliber do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "calibers" do
field :name, :string
field :slug, :string
field :category, :string
field :aliases, {:array, :string}, default: []
has_many :products, Ammoprices.Catalog.Product
timestamps(type: :utc_datetime)
end
def changeset(caliber, attrs) do
caliber
|> cast(attrs, [:name, :slug, :category, :aliases])
|> validate_required([:name, :slug, :category])
|> validate_inclusion(:category, ~w(handgun rifle rimfire shotgun))
|> unique_constraint(:slug)
end
end

View file

@ -0,0 +1,52 @@
defmodule Ammoprices.Catalog.Product do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "products" do
belongs_to :retailer, Ammoprices.Catalog.Retailer
belongs_to :caliber, Ammoprices.Catalog.Caliber
field :title, :string
field :url, :string
field :brand, :string
field :grain_weight, :integer
field :round_count, :integer
field :casing, :string
field :condition, :string, default: "new"
field :upc, :string
field :external_id, :string
field :in_stock, :boolean, default: true
field :last_seen_at, :utc_datetime
has_many :price_snapshots, Ammoprices.Prices.PriceSnapshot
timestamps(type: :utc_datetime)
end
def changeset(product, attrs) do
product
|> cast(attrs, [
:title,
:url,
:brand,
:grain_weight,
:round_count,
:casing,
:condition,
:upc,
:external_id,
:in_stock,
:last_seen_at
])
|> validate_required([:title, :url])
|> validate_inclusion(:casing, ~w(brass steel aluminum alloy composite))
|> validate_inclusion(:condition, ~w(new remanufactured surplus))
|> validate_number(:grain_weight, greater_than: 0)
|> validate_number(:round_count, greater_than: 0)
|> unique_constraint([:retailer_id, :url])
end
end

View file

@ -0,0 +1,27 @@
defmodule Ammoprices.Catalog.Retailer do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "retailers" do
field :name, :string
field :slug, :string
field :base_url, :string
field :enabled, :boolean, default: true
field :last_scraped_at, :utc_datetime
has_many :products, Ammoprices.Catalog.Product
timestamps(type: :utc_datetime)
end
def changeset(retailer, attrs) do
retailer
|> cast(attrs, [:name, :slug, :base_url, :enabled, :last_scraped_at])
|> validate_required([:name, :slug, :base_url])
|> unique_constraint(:slug)
end
end

4
lib/ammoprices/mailer.ex Normal file
View file

@ -0,0 +1,4 @@
defmodule Ammoprices.Mailer do
@moduledoc false
use Swoosh.Mailer, otp_app: :ammoprices
end

172
lib/ammoprices/prices.ex Normal file
View file

@ -0,0 +1,172 @@
defmodule Ammoprices.Prices do
@moduledoc false
import Ecto.Query
alias Ammoprices.Prices.PriceSnapshot
alias Ammoprices.Repo
def create_snapshot(product_id, attrs) do
%PriceSnapshot{product_id: product_id}
|> PriceSnapshot.changeset(attrs)
|> Repo.insert()
end
@doc """
Returns the most recent price snapshot per product for a caliber,
sorted by price_per_round_cents ASC.
Options:
- :in_stock - filter by in_stock status on the snapshot
- :limit - max results (default 100)
"""
def latest_prices_for_caliber(caliber_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
in_stock = Keyword.get(opts, :in_stock)
latest_per_product =
from(ps in PriceSnapshot,
join: p in assoc(ps, :product),
where: p.caliber_id == ^caliber_id,
distinct: ps.product_id,
order_by: [asc: ps.product_id, desc: ps.recorded_at]
)
query =
from(ps in subquery(latest_per_product),
order_by: [asc: ps.price_per_round_cents],
limit: ^limit
)
query =
if in_stock == nil do
query
else
where(query, [ps], ps.in_stock == ^in_stock)
end
Repo.all(query)
end
@doc """
Returns daily aggregates (avg, min, max) of price_per_round_cents
for in-stock products of a caliber.
Options:
- :days - number of days back (default 30)
"""
def daily_averages_for_caliber(caliber_id, opts \\ []) do
days = Keyword.get(opts, :days, 30)
since = DateTime.add(DateTime.utc_now(), -days * 86_400, :second)
Repo.all(
from(ps in PriceSnapshot,
join: p in assoc(ps, :product),
where: p.caliber_id == ^caliber_id,
where: ps.recorded_at >= ^since,
group_by: fragment("date_trunc('day', ?)", ps.recorded_at),
order_by: [asc: fragment("date_trunc('day', ?)", ps.recorded_at)],
select: %{
date: fragment("date_trunc('day', ?)::date", ps.recorded_at),
avg_ppr: fragment("round(avg(?))::integer", ps.price_per_round_cents),
min_ppr: min(ps.price_per_round_cents),
max_ppr: max(ps.price_per_round_cents)
}
)
)
end
@doc """
Returns all snapshots for a product within a date range.
Options:
- :days - number of days back (default 30)
"""
def price_history_for_product(product_id, opts \\ []) do
days = Keyword.get(opts, :days, 30)
since = DateTime.add(DateTime.utc_now(), -days * 86_400, :second)
Repo.all(
from(ps in PriceSnapshot,
where: ps.product_id == ^product_id,
where: ps.recorded_at >= ^since,
order_by: [asc: ps.recorded_at]
)
)
end
@doc """
Returns price statistics for a caliber: current_min, all_time_low, all_time_high.
Based on in-stock snapshots only.
"""
def price_stats_for_caliber(caliber_id) do
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)}
)
)
current_min =
from(ps in PriceSnapshot,
join: p in assoc(ps, :product),
where: p.caliber_id == ^caliber_id,
where: ps.in_stock == true,
distinct: ps.product_id,
order_by: [asc: ps.product_id, desc: ps.recorded_at],
select: ps.price_per_round_cents
)
|> subquery()
|> 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
}
end
@doc """
Returns the minimum price_per_round_cents for each caliber,
based on the most recent in-stock snapshots.
"""
def cheapest_per_caliber do
latest =
from(ps in PriceSnapshot,
join: p in assoc(ps, :product),
where: ps.in_stock == true,
where: p.in_stock == true,
distinct: ps.product_id,
order_by: [asc: ps.product_id, desc: ps.recorded_at],
select: %{
product_id: ps.product_id,
caliber_id: p.caliber_id,
price_per_round_cents: ps.price_per_round_cents
}
)
Repo.all(
from(s in subquery(latest),
group_by: s.caliber_id,
select: %{caliber_id: s.caliber_id, min_ppr: min(s.price_per_round_cents)}
)
)
end
end

View file

@ -0,0 +1,27 @@
defmodule Ammoprices.Prices.PriceSnapshot do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "price_snapshots" do
belongs_to :product, Ammoprices.Catalog.Product
field :price_cents, :integer
field :price_per_round_cents, :integer
field :in_stock, :boolean, default: true
field :recorded_at, :utc_datetime
timestamps(type: :utc_datetime, updated_at: false)
end
def changeset(snapshot, attrs) do
snapshot
|> cast(attrs, [:price_cents, :price_per_round_cents, :in_stock, :recorded_at])
|> validate_required([:price_cents, :price_per_round_cents, :recorded_at])
|> validate_number(:price_cents, greater_than: 0)
|> validate_number(:price_per_round_cents, greater_than: 0)
end
end

5
lib/ammoprices/repo.ex Normal file
View file

@ -0,0 +1,5 @@
defmodule Ammoprices.Repo do
use Ecto.Repo,
otp_app: :ammoprices,
adapter: Ecto.Adapters.Postgres
end

View file

@ -0,0 +1,21 @@
defmodule Ammoprices.Scraping.CaliberMatcher do
@moduledoc false
def match(title, calibers) do
downcased = String.downcase(title)
Enum.find(calibers, fn caliber ->
name_matches?(downcased, caliber) or alias_matches?(downcased, caliber)
end)
end
defp name_matches?(downcased_title, caliber) do
String.contains?(downcased_title, String.downcase(caliber.name))
end
defp alias_matches?(downcased_title, caliber) do
Enum.any?(caliber.aliases, fn a ->
String.contains?(downcased_title, String.downcase(a))
end)
end
end

View file

@ -0,0 +1,27 @@
defmodule Ammoprices.Scraping.HttpClient do
@moduledoc false
@default_headers [
{"accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"},
{"accept-language", "en-US,en;q=0.9"},
{"cache-control", "no-cache"},
{"pragma", "no-cache"},
{"sec-ch-ua", ~s("Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128")},
{"sec-ch-ua-mobile", "?0"},
{"sec-ch-ua-platform", "\"macOS\""},
{"sec-fetch-dest", "document"},
{"sec-fetch-mode", "navigate"},
{"sec-fetch-site", "none"},
{"sec-fetch-user", "?1"},
{"upgrade-insecure-requests", "1"},
{"user-agent",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"}
]
def get(url) do
Req.get(
[base_url: url, headers: @default_headers, retry: :transient, max_retries: 3],
Application.get_env(:ammoprices, :req_options, [])
)
end
end

View file

@ -0,0 +1,198 @@
defmodule Ammoprices.Scraping.Retailers.LuckyGunner do
@moduledoc false
@behaviour Ammoprices.Scraping.Scraper
@slug_to_path %{
"9mm-luger" => "/handgun/9mm-ammo",
"45-acp" => "/handgun/45-acp-ammo",
"380-acp" => "/handgun/380-ammo",
"40-sw" => "/handgun/40-sw-ammo",
"38-special" => "/handgun/38-special-ammo",
"357-magnum" => "/handgun/357-mag-ammo",
"10mm-auto" => "/handgun/10mm-ammo",
"556-223" => "/rifle/223-ammo",
"308-win" => "/rifle/308-ammo",
"762x39" => "/rifle/7.62x39-ammo",
"30-06" => "/rifle/30-06-ammo",
"300-blackout" => "/rifle/300-blackout-ammo",
"65-creedmoor" => "/rifle/6.5-creedmoor-ammo",
"22-lr" => "/rimfire/22-lr-ammo",
"22-wmr" => "/rimfire/22-wmr-ammo",
"17-hmr" => "/rimfire/17-hmr-ammo",
"12-gauge" => "/shotgun/12-gauge-ammo",
"20-gauge" => "/shotgun/20-gauge-ammo"
}
@impl true
def retailer_slug, do: "lucky-gunner"
@impl true
def category_url(caliber) do
Map.get(@slug_to_path, caliber.slug)
end
@impl true
def parse_products(html) do
html
|> Floki.parse_document!()
|> Floki.find(".products-list .item")
|> Enum.map(&parse_item/1)
|> Enum.reject(&is_nil/1)
end
defp parse_item(item) do
with {:ok, title} <- extract_title(item),
{:ok, url} <- extract_url(item),
{:ok, price_cents} <- extract_price(item),
{:ok, ppr_cents} <- extract_price_per_round(item) do
desc_items = extract_description_items(item)
%{
title: title,
url: url,
price_cents: price_cents,
price_per_round_cents: ppr_cents,
brand: extract_brand(desc_items),
grain_weight: extract_grain_weight(desc_items),
round_count: extract_round_count(title),
casing: extract_casing(desc_items),
in_stock: extract_in_stock(item)
}
else
_ -> nil
end
end
defp extract_title(item) do
case Floki.find(item, ".product-name a span") do
[{_, _, children}] -> {:ok, children |> Floki.text() |> String.trim()}
_ -> :error
end
end
defp extract_url(item) do
case Floki.find(item, ".product-name a") do
[{_, attrs, _}] ->
case List.keyfind(attrs, "href", 0) do
{"href", href} -> {:ok, href}
_ -> :error
end
_ ->
:error
end
end
defp extract_price(item) do
case Floki.find(item, ".regular-price") do
[{_, _, _} = node] ->
text = node |> Floki.text() |> String.trim()
case Regex.run(~r/\$([0-9,]+\.\d{2})/, text) do
[_, amount] ->
cents =
amount
|> String.replace(",", "")
|> String.to_float()
|> Kernel.*(100)
|> round()
{:ok, cents}
_ ->
:error
end
_ ->
:error
end
end
defp extract_price_per_round(item) do
case Floki.find(item, ".cprc") do
[{_, _, _} = node] ->
text = node |> Floki.text() |> String.trim()
case Regex.run(~r/([0-9.]+)¢/, text) do
[_, amount] ->
cents =
if String.contains?(amount, ".") do
amount |> String.to_float() |> trunc()
else
String.to_integer(amount)
end
{:ok, cents}
_ ->
:error
end
_ ->
:error
end
end
defp extract_description_items(item) do
item
|> Floki.find(".desc li")
|> Enum.map(fn node -> node |> Floki.text() |> String.trim() end)
end
defp extract_brand(desc_items) do
Enum.find_value(desc_items, fn item ->
case Regex.run(~r/Manufacturer\s*-\s*(.+)/i, item) do
[_, brand] -> String.trim(brand)
_ -> nil
end
end)
end
defp extract_grain_weight(desc_items) do
Enum.find_value(desc_items, fn item ->
case Regex.run(~r/(\d+)\s*grain/i, item) do
[_, weight] -> String.to_integer(weight)
_ -> nil
end
end)
end
defp extract_round_count(title) do
case Regex.run(~r/(\d[\d,]*)\s*[Rr]ounds?/i, title) do
[_, count] -> count |> String.replace(",", "") |> String.to_integer()
_ -> nil
end
end
defp extract_casing(desc_items) do
casing_text =
Enum.find_value(desc_items, fn item ->
case Regex.run(~r/[Cc]asings?\s*-\s*(.+)/i, item) do
[_, text] -> String.downcase(String.trim(text))
_ -> nil
end
end)
case casing_text do
nil -> nil
text when is_binary(text) -> detect_casing_material(text)
end
end
defp detect_casing_material(text) do
cond do
String.contains?(text, "brass") -> "brass"
String.contains?(text, "steel") -> "steel"
String.contains?(text, "aluminum") -> "aluminum"
String.contains?(text, "nickel") -> "brass"
true -> nil
end
end
defp extract_in_stock(item) do
case Floki.find(item, ".in-stock") do
[_ | _] -> true
_ -> false
end
end
end

View file

@ -0,0 +1,163 @@
defmodule Ammoprices.Scraping.Retailers.SgAmmo do
@moduledoc false
@behaviour Ammoprices.Scraping.Scraper
@slug_to_path %{
"9mm-luger" => "/catalog/pistol-ammo-for-sale/9mm-luger-ammo",
"45-acp" => "/catalog/pistol-ammo-for-sale/45-auto-acp-ammo",
"380-acp" => "/catalog/pistol-ammo-for-sale/380-auto-ammo",
"40-sw" => "/catalog/pistol-ammo-for-sale/40-cal-ammo",
"38-special" => "/catalog/pistol-ammo-for-sale/38-special-ammo",
"357-magnum" => "/catalog/pistol-ammo-for-sale/357-magnum-ammo",
"10mm-auto" => "/catalog/pistol-ammo-for-sale/10mm-ammo",
"556-223" => "/catalog/rifle-ammo-for-sale/223-556mm-ammo",
"308-win" => "/catalog/rifle-ammo-for-sale/308-762mm-ammo",
"762x39" => "/catalog/rifle-ammo-for-sale/762x39-ammo",
"30-06" => "/catalog/rifle-ammo-for-sale/30-06-ammo",
"300-blackout" => "/catalog/rifle-ammo-for-sale/300-aac-blackout-ammo",
"65-creedmoor" => "/catalog/rifle-ammo-for-sale/65-creedmoor-ammo",
"22-lr" => "/catalog/rimfire-ammo-for-sale/22-lr-ammo",
"22-wmr" => "/catalog/rimfire-ammo-for-sale/22-wmr-ammo",
"17-hmr" => "/catalog/rimfire-ammo-for-sale/17-hmr-ammo",
"12-gauge" => "/catalog/shotgun-ammo-for-sale/12-gauge-ammo",
"20-gauge" => "/catalog/shotgun-ammo-for-sale/20-gauge-ammo"
}
@impl true
def retailer_slug, do: "sgammo"
@impl true
def category_url(caliber) do
Map.get(@slug_to_path, caliber.slug)
end
@impl true
def parse_products(html) do
html
|> Floki.parse_document!()
|> Floki.find("tr.sgammo-content-product-item")
|> Enum.map(&parse_row/1)
|> Enum.reject(&is_nil/1)
end
defp parse_row(row) do
with {:ok, title} <- extract_title(row),
{:ok, url} <- extract_url(row),
{:ok, price_cents} <- extract_price(row),
{:ok, ppr_cents} <- extract_price_per_round(row) do
%{
title: title,
url: url,
price_cents: price_cents,
price_per_round_cents: ppr_cents,
brand: extract_brand(title),
grain_weight: extract_grain_weight(title),
round_count: extract_round_count(title),
casing: nil,
in_stock: extract_in_stock(row)
}
else
_ -> nil
end
end
defp extract_title(row) do
case Floki.find(row, ".sgammo-content-product-item__title a") do
[{_, _, _} = node] -> {:ok, node |> Floki.text() |> String.trim()}
_ -> :error
end
end
defp extract_url(row) do
case Floki.find(row, ".sgammo-content-product-item__title a") do
[{_, attrs, _}] ->
case List.keyfind(attrs, "href", 0) do
{"href", href} -> {:ok, href}
_ -> :error
end
_ ->
:error
end
end
defp extract_price(row) do
case Floki.find(row, ".sgammo-content-product-item__col-price .woocommerce-Price-amount") do
[{_, _, _} = node | _] ->
text = node |> Floki.text() |> String.trim()
case Regex.run(~r/\$([0-9,]+\.\d{2})/, text) do
[_, amount] ->
cents =
amount
|> String.replace(",", "")
|> String.to_float()
|> Kernel.*(100)
|> round()
{:ok, cents}
_ ->
:error
end
_ ->
:error
end
end
defp extract_price_per_round(row) do
price_col =
row
|> Floki.find(".sgammo-content-product-item__col-price")
|> List.first()
if price_col do
text = Floki.text(price_col)
case Regex.run(~r/\$([0-9.]+)\s*Per Round/i, text) do
[_, amount] ->
cents =
amount
|> String.to_float()
|> Kernel.*(100)
|> round()
{:ok, cents}
_ ->
:error
end
else
:error
end
end
defp extract_brand(title) do
# SGAmmo titles typically have format: "NNN Round ... by BRAND - SKU"
case Regex.run(~r/by\s+([A-Za-z][A-Za-z\s&]+?)(?:\s*-|\s*$)/i, title) do
[_, brand] -> String.trim(brand)
_ -> nil
end
end
defp extract_grain_weight(title) do
case Regex.run(~r/(\d+)\s*[Gg]rain/i, title) do
[_, weight] -> String.to_integer(weight)
_ -> nil
end
end
defp extract_round_count(title) do
case Regex.run(~r/(\d[\d,]*)\s*[Rr]ound/i, title) do
[_, count] -> count |> String.replace(",", "") |> String.to_integer()
_ -> nil
end
end
defp extract_in_stock(row) do
{_, _attrs, _} = row
classes = [row] |> Floki.attribute("class") |> List.first() || ""
String.contains?(classes, "instock")
end
end

View file

@ -0,0 +1,71 @@
defmodule Ammoprices.Scraping.Runner do
@moduledoc false
alias Ammoprices.Catalog
alias Ammoprices.Prices
alias Ammoprices.Scraping.HttpClient
def run(scraper, caliber) do
retailer = Catalog.get_retailer_by_slug!(scraper.retailer_slug())
case scraper.category_url(caliber) do
nil ->
{:ok, %{products_count: 0, snapshots_count: 0}}
path ->
url = retailer.base_url <> path
do_scrape(scraper, retailer, caliber, url)
end
end
defp do_scrape(scraper, retailer, caliber, url) do
case HttpClient.get(url) do
{:ok, %{status: 200, body: body}} ->
now = DateTime.truncate(DateTime.utc_now(), :second)
parsed = scraper.parse_products(body)
results =
Enum.map(parsed, fn product_data ->
product_attrs = %{
title: product_data.title,
url: product_data.url,
brand: product_data.brand,
grain_weight: product_data.grain_weight,
round_count: product_data.round_count,
casing: product_data.casing,
condition: "new",
in_stock: product_data.in_stock,
last_seen_at: now
}
case Catalog.upsert_product(retailer.id, caliber.id, product_attrs) do
{:ok, product} ->
snapshot_attrs = %{
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}}
{:ok, %{status: status}} ->
{:error, "HTTP #{status}"}
{:error, reason} ->
{:error, reason}
end
end
end

View file

@ -0,0 +1,36 @@
defmodule Ammoprices.Scraping.ScrapeJob do
@moduledoc false
use Oban.Worker, queue: :scraping, max_attempts: 3
alias Ammoprices.Catalog
alias Ammoprices.Scraping.Runner
@scrapers [
Ammoprices.Scraping.Retailers.LuckyGunner,
Ammoprices.Scraping.Retailers.SgAmmo
]
@impl Oban.Worker
def perform(_job) do
calibers = Catalog.list_calibers()
for scraper <- @scrapers, caliber <- calibers do
scrape_delay()
Runner.run(scraper, caliber)
end
Phoenix.PubSub.broadcast(Ammoprices.PubSub, "prices:updated", {:prices_updated, %{}})
:ok
end
# Random delay between requests: 5-15 seconds in prod, 0 in test
defp scrape_delay do
{min, max} = Application.get_env(:ammoprices, :scrape_delay_ms, {5_000, 15_000})
delay = Enum.random(min..max)
if delay > 0 do
Process.sleep(delay)
end
end
end

View file

@ -0,0 +1,7 @@
defmodule Ammoprices.Scraping.Scraper do
@moduledoc false
@callback retailer_slug() :: String.t()
@callback category_url(caliber :: map()) :: String.t() | nil
@callback parse_products(html :: String.t()) :: [map()]
end

115
lib/ammoprices_web.ex Normal file
View file

@ -0,0 +1,115 @@
defmodule AmmopricesWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use AmmopricesWeb, :controller
use AmmopricesWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
import Phoenix.Controller
import Phoenix.LiveView.Router
# Import common connection and controller functions to use in pipelines
import Plug.Conn
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
use Gettext, backend: AmmopricesWeb.Gettext
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView
unquote(html_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
end
end
def html do
quote do
use Phoenix.Component
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end
end
defp html_helpers do
quote do
# Translation
use Gettext, backend: AmmopricesWeb.Gettext
import AmmopricesWeb.CoreComponents
# HTML escaping functionality
import Phoenix.HTML
# Core UI components
alias AmmopricesWeb.Layouts
# Common modules used in templates
alias Phoenix.LiveView.JS
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: AmmopricesWeb.Endpoint,
router: AmmopricesWeb.Router,
statics: AmmopricesWeb.static_paths()
end
end
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View file

@ -0,0 +1,496 @@
defmodule AmmopricesWeb.CoreComponents do
@moduledoc """
Provides core UI components.
At first glance, this module may seem daunting, but its goal is to provide
core building blocks for your application, such as tables, forms, and
inputs. The components consist mostly of markup and are well-documented
with doc strings and declarative assigns. You may customize and style
them in any way you want, based on your application growth and needs.
The foundation for styling is Tailwind CSS, a utility-first CSS framework,
augmented with daisyUI, a Tailwind CSS plugin that provides UI components
and themes. Here are useful references:
* [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
started and see the available components.
* [Tailwind CSS](https://tailwindcss.com) - the foundational framework
we build on. You will use it for layout, sizing, flexbox, grid, and
spacing.
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
* [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
the component system used by Phoenix. Some components, such as `<.link>`
and `<.form>`, are defined there.
"""
use Phoenix.Component
use Gettext, backend: AmmopricesWeb.Gettext
alias Phoenix.HTML.FormField
alias Phoenix.LiveView.JS
@doc """
Renders flash notices.
## Examples
<.flash kind={:info} flash={@flash} />
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
"""
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc """
Renders a button with navigation support.
## Examples
<.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :any
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc """
Renders an input with label and error messages.
A `Phoenix.HTML.FormField` may be passed as argument,
which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.
## Types
This function accepts all HTML input types, considering that:
* You may also set `type="select"` to render a `<select>` tag
* `type="checkbox"` is used exclusively to render boolean values
* For live file uploads, see `Phoenix.Component.live_file_input/1`
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
for more information. Unsupported types, such as radio, are best
written directly in your templates.
## Examples
```heex
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
```
## Select type
When using `type="select"`, you must pass the `options` and optionally
a `value` to mark which option should be preselected.
```heex
<.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} />
```
For more information on what kind of data can be passed to `options` see
[`options_for_select`](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#options_for_select/2).
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week hidden)
attr :field, FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :any, default: nil, doc: "the input class to use over defaults"
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "hidden"} = assigns) do
~H"""
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
"""
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="fieldset mb-2">
<label>
<input
type="hidden"
name={@name}
value="false"
disabled={@rest[:disabled]}
form={@rest[:form]}
/>
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc """
Renders a header with title.
"""
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc """
Renders a table with generic styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}</:col>
<:col :let={user} label="username">{user.username}</:col>
</.table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc """
Renders a data list.
## Examples
<.list>
<:item title="Title">{@post.title}</:item>
<:item title="Views">{@post.views}</:item>
</.list>
"""
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc """
Renders a [Heroicon](https://heroicons.com).
Heroicons come in three styles outline, solid, and mini.
By default, the outline style is used, but solid and mini may
be applied by using the `-solid` and `-mini` suffix.
You can customize the size and colors of the icons by setting
width, height, and background color classes.
Icons are extracted from the `deps/heroicons` directory and bundled within
your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
## Examples
<.icon name="hero-x-mark" />
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
"""
attr :name, :string, required: true
attr :class, :any, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# However the error messages in our forms and APIs are generated
# dynamically, so we need to translate them by calling Gettext
# with our gettext backend as first argument. Translations are
# available in the errors.po file (as we use the "errors" domain).
if count = opts[:count] do
Gettext.dngettext(AmmopricesWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(AmmopricesWeb.Gettext, "errors", msg, opts)
end
end
@doc """
Translates the errors for a field from a keyword list of errors.
"""
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end

View file

@ -0,0 +1,145 @@
defmodule AmmopricesWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use AmmopricesWeb, :html
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Renders your app layout.
This function is typically invoked from every template,
and it often contains your application menu, sidebar,
or similar.
## Examples
<Layouts.app flash={@flash}>
<h1>Content</h1>
</Layouts.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="sticky top-0 z-50 border-b border-base-300 bg-base-100/95 backdrop-blur-sm">
<nav class="mx-auto max-w-5xl flex items-center justify-between px-4 py-2.5 sm:px-6">
<a href="/" class="flex items-center gap-2 group">
<span class="text-lg font-black tracking-tight text-base-content group-hover:text-primary transition-colors">
AmmoTracker
</span>
</a>
<div class="flex items-center gap-1">
<a
href="/"
class="px-2.5 py-1.5 text-xs font-semibold rounded-md text-base-content/70 hover:text-base-content hover:bg-base-200 transition-all"
>
Home
</a>
<.theme_toggle />
</div>
</nav>
</header>
<main class="mx-auto max-w-5xl px-4 py-6 sm:px-6">
{render_slot(@inner_block)}
</main>
<.flash_group flash={@flash} />
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
"""
end
@doc """
Provides dark vs light theme toggle based on themes defined in app.css.
See <head> in root.html.heex which applies the theme before page load.
"""
def theme_toggle(assigns) do
~H"""
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" />
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="system"
>
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="light"
>
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="dark"
>
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
</div>
"""
end
end

View file

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="Ammoprices" suffix=" · Phoenix Framework">
{assigns[:page_title]}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script>
<script>
(() => {
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
}
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
})();
</script>
</head>
<body>
{@inner_content}
</body>
</html>

View file

@ -0,0 +1,79 @@
defmodule AmmopricesWeb.PriceComponents do
@moduledoc false
use Phoenix.Component
attr :cents, :integer, required: true
attr :class, :string, default: nil
def price_per_round(assigns) do
~H"""
<span class={["font-mono tabular-nums", @class]}>
{format_cpr(@cents)}
</span>
"""
end
attr :in_stock, :boolean, required: true
attr :class, :string, default: nil
def stock_badge(assigns) do
~H"""
<span class={[
"inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wider",
if(@in_stock, do: "bg-success/15 text-success", else: "bg-error/15 text-error"),
@class
]}>
<span class={[
"inline-block w-1.5 h-1.5 rounded-full",
if(@in_stock, do: "bg-success", else: "bg-error")
]} />
{if @in_stock, do: "In Stock", else: "Out of Stock"}
</span>
"""
end
attr :url, :string, required: true
attr :name, :string, required: true
attr :class, :string, default: nil
def retailer_link(assigns) do
~H"""
<a
href={@url}
target="_blank"
rel="noopener noreferrer"
class={[
"inline-flex items-center gap-1 text-primary hover:text-primary/80 transition-colors",
@class
]}
>
{@name}
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
"""
end
defp format_cpr(nil), do: "--"
defp format_cpr(cents) when cents < 100 do
"#{cents}¢/rd"
end
defp format_cpr(cents) do
dollars = cents / 100
"$#{:erlang.float_to_binary(dollars, decimals: 2)}/rd"
end
end

View file

@ -0,0 +1,24 @@
defmodule AmmopricesWeb.ErrorHTML do
@moduledoc """
This module is invoked by your endpoint in case of errors on HTML requests.
See config/config.exs.
"""
use AmmopricesWeb, :html
# If you want to customize your error pages,
# uncomment the embed_templates/1 call below
# and add pages to the error directory:
#
# * lib/ammoprices_web/controllers/error_html/404.html.heex
# * lib/ammoprices_web/controllers/error_html/500.html.heex
#
# embed_templates "error_html/*"
# The default is to render a plain text page based on
# the template name. For example, "404.html" becomes
# "Not Found".
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end

View file

@ -0,0 +1,21 @@
defmodule AmmopricesWeb.ErrorJSON do
@moduledoc """
This module is invoked by your endpoint in case of errors on JSON requests.
See config/config.exs.
"""
# If you want to customize a particular status code,
# you may add your own clauses, such as:
#
# def render("500.json", _assigns) do
# %{errors: %{detail: "Internal Server Error"}}
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.json" becomes
# "Not Found".
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end

View file

@ -0,0 +1,7 @@
defmodule AmmopricesWeb.PageController do
use AmmopricesWeb, :controller
def home(conn, _params) do
render(conn, :home)
end
end

View file

@ -0,0 +1,10 @@
defmodule AmmopricesWeb.PageHTML do
@moduledoc """
This module contains pages rendered by PageController.
See the `page_html` directory for all templates available.
"""
use AmmopricesWeb, :html
embed_templates "page_html/*"
end

View file

@ -0,0 +1,202 @@
<Layouts.flash_group flash={@flash} />
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
<svg
viewBox="0 0 1480 957"
fill="none"
aria-hidden="true"
class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/>
<path
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
fill="#FA8372"
/>
<path
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
fill="#E96856"
fill-opacity=".6"
/>
<path
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
fill-opacity=".2"
/>
<path
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
fill="#A41C42"
fill-opacity=".2"
/>
<path
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
fill="#A41C42"
fill-opacity=".2"
/>
</svg>
</div>
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
<div class="mx-auto max-w-xl lg:mx-0">
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>
<div class="mt-10 flex justify-between items-center">
<h1 class="flex items-center text-sm font-semibold leading-6">
Phoenix Framework
<small class="badge badge-warning badge-sm ml-3">
v{Application.spec(:phoenix, :vsn)}
</small>
</h1>
<Layouts.theme_toggle />
</div>
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
Peace of mind from prototype to production.
</p>
<p class="mt-4 leading-7 text-base-content/70">
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
</p>
<div class="flex">
<div class="w-full sm:w-auto">
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
<a
href="https://hexdocs.pm/phoenix/overview.html"
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" />
<path
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Guides &amp; Docs
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix"
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
/>
</svg>
Source Code
</span>
</a>
<a
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path
d="M12 1v6M12 17v6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
fill="currentColor"
fill-opacity=".15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Changelog
</span>
</a>
</div>
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
<div>
<a
href="https://elixirforum.com"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
</svg>
Discuss on the Elixir Forum
</a>
</div>
<div>
<a
href="https://discord.gg/elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
</svg>
Join our Discord server
</a>
</div>
<div>
<a
href="https://elixir-slack.community/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" />
</svg>
Join us on Slack
</a>
</div>
<div>
<a
href="https://fly.io/docs/elixir/getting-started/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
</svg>
Deploy your application
</a>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,55 @@
defmodule AmmopricesWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :ammoprices
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
key: "_ammoprices_key",
signing_salt: "4zemBm8P",
same_site: "Lax"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# When code reloading is disabled (e.g., in production),
# the `gzip` option is enabled to serve compressed
# static files generated by running `phx.digest`.
plug Plug.Static,
at: "/",
from: :ammoprices,
gzip: not code_reloading?,
only: AmmopricesWeb.static_paths(),
raise_on_missing_only: code_reloading?
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :ammoprices
end
plug Phoenix.LiveDashboard.RequestLogger,
param_key: "request_logger",
cookie_key: "request_logger"
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug AmmopricesWeb.Router
end

View file

@ -0,0 +1,25 @@
defmodule AmmopricesWeb.Gettext do
@moduledoc """
A module providing Internationalization with a gettext-based API.
By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
that you can use in your application. To use this Gettext backend module,
call `use Gettext` and pass it as an option:
use Gettext, backend: AmmopricesWeb.Gettext
# Simple translation
gettext("Here is the string to translate")
# Plural translation
ngettext("Here is the string to translate",
"Here are the strings to translate",
3)
# Domain-based translation
dgettext("errors", "Here is the error message to translate")
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
"""
use Gettext.Backend, otp_app: :ammoprices
end

View file

@ -0,0 +1,471 @@
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,
chart_range: "30d"
)
|> 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("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_products()
|> load_chart_data()
|> load_price_stats()
{:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})}
end
defp load_products(socket) do
%{caliber: caliber, in_stock_filter: in_stock_filter} = socket.assigns
opts =
if in_stock_filter do
[in_stock: true, limit: 200]
else
[limit: 200]
end
snapshots = Prices.latest_prices_for_caliber(caliber.id, opts)
# Preload product+retailer for each snapshot
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) when cents < 100 do
"#{cents}\u00A2"
end
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"
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 class="flex items-center gap-3">
<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>
</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">
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">
<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>
<div class="sm:hidden text-xs text-base-content/50 mt-0.5">
{row.product.retailer.name}
</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 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
if (val < 100) return ctx.dataset.label + ": " + val + "¢/rd"
return ctx.dataset.label + ": $" + (val / 100).toFixed(2) + "/rd"
}
}
}
},
scales: {
x: {
type: "category",
ticks: {
maxTicksLimit: 8,
font: { size: 10 }
},
grid: { display: false }
},
y: {
ticks: {
callback: function(val) {
if (val < 100) return 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

View file

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

View file

@ -0,0 +1,45 @@
defmodule AmmopricesWeb.Router do
use AmmopricesWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {AmmopricesWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", AmmopricesWeb do
pipe_through :browser
live "/", HomeLive
live "/calibers/:slug", CaliberLive.Show
end
# Other scopes may use custom stacks.
# scope "/api", AmmopricesWeb do
# pipe_through :api
# end
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:ammoprices, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
import Phoenix.LiveDashboard.Router
scope "/dev" do
pipe_through :browser
live_dashboard "/dashboard", metrics: AmmopricesWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
end

View file

@ -0,0 +1,94 @@
defmodule AmmopricesWeb.Telemetry do
@moduledoc false
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
# Telemetry poller will execute the given period measurements
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
# Add reporters as children of your supervision tree.
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# Phoenix Metrics
summary("phoenix.endpoint.start.system_time",
unit: {:native, :millisecond}
),
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.start.system_time",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.exception.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.stop.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.socket_connected.duration",
unit: {:native, :millisecond}
),
sum("phoenix.socket_drain.count"),
summary("phoenix.channel_joined.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_handled_in.duration",
tags: [:event],
unit: {:native, :millisecond}
),
# Database Metrics
summary("ammoprices.repo.query.total_time",
unit: {:native, :millisecond},
description: "The sum of the other measurements"
),
summary("ammoprices.repo.query.decode_time",
unit: {:native, :millisecond},
description: "The time spent decoding the data received from the database"
),
summary("ammoprices.repo.query.query_time",
unit: {:native, :millisecond},
description: "The time spent executing the query"
),
summary("ammoprices.repo.query.queue_time",
unit: {:native, :millisecond},
description: "The time spent waiting for a database connection"
),
summary("ammoprices.repo.query.idle_time",
unit: {:native, :millisecond},
description: "The time the connection spent waiting before being checked out for the query"
),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
]
end
defp periodic_measurements do
[
# A module, function and arguments to be invoked periodically.
# This function must call :telemetry.execute/3 and a metric must be added above.
# {AmmopricesWeb, :count_users, []}
]
end
end

92
mix.exs Normal file
View file

@ -0,0 +1,92 @@
defmodule Ammoprices.MixProject do
use Mix.Project
def project do
[
app: :ammoprices,
version: "0.1.0",
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
compilers: [:phoenix_live_view] ++ Mix.compilers(),
listeners: [Phoenix.CodeReloader]
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {Ammoprices.Application, []},
extra_applications: [:logger, :runtime_tools]
]
end
def cli do
[
preferred_envs: [precommit: :test]
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
[
{:phoenix, "~> 1.8.3"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.1.0"},
{:lazy_html, ">= 0.1.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
{:heroicons,
github: "tailwindlabs/heroicons", tag: "v2.2.0", sparse: "optimized", app: false, compile: false, depth: 1},
{:swoosh, "~> 1.16"},
{:floki, "~> 0.37"},
{:oban, "~> 2.19"},
{:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 1.0"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"},
{:styler, "~> 1.4", only: [:dev, :test], runtime: false}
]
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to install project dependencies and perform other setup tasks, run:
#
# $ mix setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
"assets.build": ["compile", "tailwind ammoprices", "esbuild ammoprices"],
"assets.deploy": [
"tailwind ammoprices --minify",
"esbuild ammoprices --minify",
"phx.digest"
],
precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
]
end
end

49
mix.lock Normal file
View file

@ -0,0 +1,49 @@
%{
"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"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"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"},
"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"},
"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"},
"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"},
"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"},
"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"},
"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_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_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"},
"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"},
"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"},
"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"},
}

View file

@ -0,0 +1,112 @@
## `msgid`s in this file come from POT (.pot) files.
##
## Do not add, change, or remove `msgid`s manually here as
## they're tied to the ones in the corresponding POT file
## (with the same domain).
##
## Use `mix gettext.extract --merge` or `mix gettext.merge`
## to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""

109
priv/gettext/errors.pot Normal file
View file

@ -0,0 +1,109 @@
## This is a PO Template file.
##
## `msgid`s here are often extracted from source code.
## Add new translations manually only if they're dynamic
## translations that can't be statically extracted.
##
## Run `mix gettext.extract` to bring this file up to
## date. Leave `msgstr`s empty as changing them here has no
## effect: edit them in PO (`.po`) files instead.
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""

View file

@ -0,0 +1,4 @@
[
import_deps: [:ecto_sql],
inputs: ["*.exs"]
]

View file

@ -0,0 +1,18 @@
defmodule Ammoprices.Repo.Migrations.CreateRetailers do
use Ecto.Migration
def change do
create table(:retailers, primary_key: false) do
add :id, :binary_id, primary_key: true
add :name, :string, null: false
add :slug, :string, null: false
add :base_url, :string, null: false
add :enabled, :boolean, default: true, null: false
add :last_scraped_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create unique_index(:retailers, [:slug])
end
end

View file

@ -0,0 +1,17 @@
defmodule Ammoprices.Repo.Migrations.CreateCalibers do
use Ecto.Migration
def change do
create table(:calibers, primary_key: false) do
add :id, :binary_id, primary_key: true
add :name, :string, null: false
add :slug, :string, null: false
add :category, :string, null: false
add :aliases, {:array, :string}, default: []
timestamps(type: :utc_datetime)
end
create unique_index(:calibers, [:slug])
end
end

View file

@ -0,0 +1,32 @@
defmodule Ammoprices.Repo.Migrations.CreateProducts do
use Ecto.Migration
def change do
create table(:products, primary_key: false) do
add :id, :binary_id, primary_key: true
add :retailer_id, references(:retailers, type: :binary_id, on_delete: :delete_all),
null: false
add :caliber_id, references(:calibers, type: :binary_id, on_delete: :delete_all),
null: false
add :title, :string, null: false
add :url, :string, null: false
add :brand, :string
add :grain_weight, :integer
add :round_count, :integer
add :casing, :string
add :condition, :string, default: "new"
add :upc, :string
add :external_id, :string
add :in_stock, :boolean, default: true, null: false
add :last_seen_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create unique_index(:products, [:retailer_id, :url])
create index(:products, [:caliber_id, :in_stock])
end
end

View file

@ -0,0 +1,22 @@
defmodule Ammoprices.Repo.Migrations.CreatePriceSnapshots do
use Ecto.Migration
def change do
create table(:price_snapshots, primary_key: false) do
add :id, :binary_id, primary_key: true
add :product_id, references(:products, type: :binary_id, on_delete: :delete_all),
null: false
add :price_cents, :integer, null: false
add :price_per_round_cents, :integer, null: false
add :in_stock, :boolean, default: true, null: false
add :recorded_at, :utc_datetime, null: false
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:price_snapshots, [:product_id, :recorded_at])
create index(:price_snapshots, [:recorded_at])
end
end

View file

@ -0,0 +1,11 @@
defmodule Ammoprices.Repo.Migrations.AddObanJobsTable do
use Ecto.Migration
def up do
Oban.Migration.up(version: 12)
end
def down do
Oban.Migration.down(version: 1)
end
end

85
priv/repo/seeds.exs Normal file
View file

@ -0,0 +1,85 @@
alias Ammoprices.Catalog.Caliber
alias Ammoprices.Catalog.Retailer
alias Ammoprices.Repo
# Retailers
retailers = [
%{
name: "Lucky Gunner",
slug: "lucky-gunner",
base_url: "https://www.luckygunner.com"
},
%{
name: "SGAmmo",
slug: "sgammo",
base_url: "https://www.sgammo.com"
}
]
for attrs <- retailers do
%Retailer{}
|> Retailer.changeset(attrs)
|> Repo.insert!(on_conflict: :nothing, conflict_target: :slug)
end
# Calibers by category
calibers = [
# Handgun
%{name: "9mm Luger", slug: "9mm-luger", category: "handgun", aliases: ["9mm", "9x19", "9mm parabellum", "9mm nato"]},
%{name: ".45 ACP", slug: "45-acp", category: "handgun", aliases: [".45 acp", "45 acp", ".45 auto", "45 auto"]},
%{name: ".380 ACP", slug: "380-acp", category: "handgun", aliases: [".380", "380 acp", ".380 auto", "380 auto"]},
%{name: ".40 S&W", slug: "40-sw", category: "handgun", aliases: [".40 s&w", "40 s&w", ".40 cal", "40 cal"]},
%{
name: ".38 Special",
slug: "38-special",
category: "handgun",
aliases: [".38 spl", "38 special", ".38 spc", "38 spl"]
},
%{name: ".357 Magnum", slug: "357-magnum", category: "handgun", aliases: [".357 mag", "357 mag", "357 magnum"]},
%{name: "10mm Auto", slug: "10mm-auto", category: "handgun", aliases: ["10mm", "10mm auto"]},
# Rifle
%{
name: "5.56x45 / .223 Rem",
slug: "556-223",
category: "rifle",
aliases: ["5.56", "5.56x45", ".223", ".223 rem", "223 remington", "5.56 nato"]
},
%{
name: ".308 Win / 7.62x51",
slug: "308-win",
category: "rifle",
aliases: [".308", ".308 win", "308 winchester", "7.62x51", "7.62 nato"]
},
%{name: "7.62x39", slug: "762x39", category: "rifle", aliases: ["7.62x39", "7.62x39mm"]},
%{
name: ".30-06 Springfield",
slug: "30-06",
category: "rifle",
aliases: [".30-06", "30-06", "30-06 springfield", ".30-06 sprg"]
},
%{
name: ".300 Blackout",
slug: "300-blackout",
category: "rifle",
aliases: [".300 blk", "300 blackout", ".300 aac blackout", "300 blk"]
},
%{name: "6.5 Creedmoor", slug: "65-creedmoor", category: "rifle", aliases: ["6.5 creedmoor", "6.5cm", "6.5 cm"]},
# Rimfire
%{name: ".22 LR", slug: "22-lr", category: "rimfire", aliases: [".22 lr", "22 lr", ".22 long rifle", "22 long rifle"]},
%{name: ".22 WMR", slug: "22-wmr", category: "rimfire", aliases: [".22 wmr", "22 wmr", ".22 magnum", "22 mag"]},
%{name: ".17 HMR", slug: "17-hmr", category: "rimfire", aliases: [".17 hmr", "17 hmr", ".17 hornady magnum"]},
# Shotgun
%{name: "12 Gauge", slug: "12-gauge", category: "shotgun", aliases: ["12 gauge", "12 ga", "12ga"]},
%{name: "20 Gauge", slug: "20-gauge", category: "shotgun", aliases: ["20 gauge", "20 ga", "20ga"]}
]
for attrs <- calibers do
%Caliber{}
|> Caliber.changeset(attrs)
|> Repo.insert!(on_conflict: :nothing, conflict_target: :slug)
end
IO.puts("Seeded #{length(retailers)} retailers and #{length(calibers)} calibers")

BIN
priv/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

5
priv/static/robots.txt Normal file
View file

@ -0,0 +1,5 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

View file

@ -0,0 +1,155 @@
defmodule Ammoprices.CatalogTest do
use Ammoprices.DataCase, async: true
import Ammoprices.Fixtures
alias Ammoprices.Catalog
describe "retailers" do
test "list_retailers/0 returns all retailers" do
retailer = retailer_fixture()
assert Catalog.list_retailers() == [retailer]
end
test "get_retailer!/1 returns the retailer with given id" do
retailer = retailer_fixture()
assert Catalog.get_retailer!(retailer.id) == retailer
end
test "get_retailer_by_slug!/1 returns the retailer with given slug" do
retailer = retailer_fixture(%{slug: "lucky-gunner"})
assert Catalog.get_retailer_by_slug!("lucky-gunner") == retailer
end
test "create_retailer/1 with valid data creates a retailer" do
attrs = %{name: "Lucky Gunner", slug: "lucky-gunner", base_url: "https://luckygunner.com"}
assert {:ok, retailer} = Catalog.create_retailer(attrs)
assert retailer.name == "Lucky Gunner"
assert retailer.slug == "lucky-gunner"
assert retailer.base_url == "https://luckygunner.com"
assert retailer.enabled == true
end
test "create_retailer/1 with duplicate slug returns error" do
retailer_fixture(%{slug: "duplicate"})
attrs = %{name: "Another", slug: "duplicate", base_url: "https://example.com"}
assert {:error, changeset} = Catalog.create_retailer(attrs)
assert %{slug: ["has already been taken"]} = errors_on(changeset)
end
test "create_retailer/1 with missing required fields returns error" do
assert {:error, changeset} = Catalog.create_retailer(%{})
assert %{name: ["can't be blank"]} = errors_on(changeset)
end
test "update_retailer/2 updates the retailer" do
retailer = retailer_fixture()
assert {:ok, updated} = Catalog.update_retailer(retailer, %{name: "Updated Name"})
assert updated.name == "Updated Name"
end
end
describe "calibers" do
test "list_calibers/0 returns all calibers" do
caliber = caliber_fixture()
assert Catalog.list_calibers() == [caliber]
end
test "list_calibers_by_category/1 filters by category" do
handgun = caliber_fixture(%{name: "9mm", slug: "9mm", category: "handgun"})
_rifle = caliber_fixture(%{name: "5.56", slug: "556", category: "rifle"})
result = Catalog.list_calibers_by_category("handgun")
assert result == [handgun]
end
test "get_caliber!/1 returns the caliber with given id" do
caliber = caliber_fixture()
assert Catalog.get_caliber!(caliber.id) == caliber
end
test "get_caliber_by_slug!/1 returns the caliber with given slug" do
caliber = caliber_fixture(%{slug: "9mm-luger"})
assert Catalog.get_caliber_by_slug!("9mm-luger") == caliber
end
test "create_caliber/1 with valid data creates a caliber" do
attrs = %{
name: ".45 ACP",
slug: "45-acp",
category: "handgun",
aliases: [".45", "45 auto"]
}
assert {:ok, caliber} = Catalog.create_caliber(attrs)
assert caliber.name == ".45 ACP"
assert caliber.aliases == [".45", "45 auto"]
end
test "create_caliber/1 with invalid category returns error" do
attrs = %{name: "Test", slug: "test", category: "invalid"}
assert {:error, changeset} = Catalog.create_caliber(attrs)
assert %{category: ["is invalid"]} = errors_on(changeset)
end
end
describe "products" do
test "upsert_product/3 creates a new product" do
retailer = retailer_fixture()
caliber = caliber_fixture()
attrs = %{
title: "Federal 9mm 115gr FMJ",
url: "/products/federal-9mm",
brand: "Federal",
grain_weight: 115,
round_count: 50,
casing: "brass",
condition: "new",
in_stock: true,
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, product} = Catalog.upsert_product(retailer.id, caliber.id, attrs)
assert product.title == "Federal 9mm 115gr FMJ"
assert product.retailer_id == retailer.id
assert product.caliber_id == caliber.id
end
test "upsert_product/3 updates existing product on duplicate retailer_id + url" do
retailer = retailer_fixture()
caliber = caliber_fixture()
attrs = %{
title: "Federal 9mm 115gr FMJ",
url: "/products/federal-9mm",
brand: "Federal",
grain_weight: 115,
round_count: 50,
casing: "brass",
in_stock: true,
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, product1} = Catalog.upsert_product(retailer.id, caliber.id, attrs)
assert {:ok, product2} =
Catalog.upsert_product(retailer.id, caliber.id, %{attrs | title: "Updated Title", in_stock: false})
assert product1.id == product2.id
assert product2.title == "Updated Title"
assert product2.in_stock == false
end
test "list_products_for_caliber/2 returns products for a caliber" do
caliber = caliber_fixture()
retailer = retailer_fixture()
product = product_fixture(%{caliber: caliber, retailer: retailer})
_other = product_fixture()
results = Catalog.list_products_for_caliber(caliber.id)
assert length(results) == 1
assert hd(results).id == product.id
end
end
end

View file

@ -0,0 +1,230 @@
defmodule Ammoprices.PricesTest do
use Ammoprices.DataCase, async: true
import Ammoprices.Fixtures
alias Ammoprices.Prices
describe "create_snapshot/2" do
test "creates a price snapshot for a product" do
product = product_fixture()
now = DateTime.truncate(DateTime.utc_now(), :second)
attrs = %{
price_cents: 1599,
price_per_round_cents: 32,
in_stock: true,
recorded_at: now
}
assert {:ok, snapshot} = Prices.create_snapshot(product.id, attrs)
assert snapshot.product_id == product.id
assert snapshot.price_cents == 1599
assert snapshot.price_per_round_cents == 32
assert snapshot.recorded_at == now
end
test "returns error with invalid data" do
product = product_fixture()
assert {:error, changeset} = Prices.create_snapshot(product.id, %{})
assert %{price_cents: ["can't be blank"]} = errors_on(changeset)
end
end
describe "latest_prices_for_caliber/2" do
test "returns most recent snapshot per product sorted by price_per_round" do
caliber = caliber_fixture()
retailer = retailer_fixture()
cheap_product = product_fixture(%{caliber: caliber, retailer: retailer, title: "Cheap"})
expensive_product = product_fixture(%{caliber: caliber, retailer: retailer, title: "Expensive"})
now = DateTime.truncate(DateTime.utc_now(), :second)
old = DateTime.add(now, -3600, :second)
# Old snapshot for cheap product (higher price)
snapshot_fixture(%{product: cheap_product, price_per_round_cents: 50, recorded_at: old})
# Latest snapshot for cheap product (lower price)
snapshot_fixture(%{product: cheap_product, price_per_round_cents: 25, recorded_at: now})
# Latest snapshot for expensive product
snapshot_fixture(%{product: expensive_product, price_per_round_cents: 40, recorded_at: now})
results = Prices.latest_prices_for_caliber(caliber.id)
assert length(results) == 2
# Sorted by price_per_round_cents ASC
assert hd(results).price_per_round_cents == 25
assert List.last(results).price_per_round_cents == 40
end
test "filters by in_stock" do
caliber = caliber_fixture()
retailer = retailer_fixture()
now = DateTime.truncate(DateTime.utc_now(), :second)
in_stock_product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
oos_product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: false})
snapshot_fixture(%{product: in_stock_product, in_stock: true, recorded_at: now})
snapshot_fixture(%{product: oos_product, in_stock: false, recorded_at: now})
results = Prices.latest_prices_for_caliber(caliber.id, in_stock: true)
assert length(results) == 1
assert hd(results).in_stock == true
end
test "respects limit option" do
caliber = caliber_fixture()
retailer = retailer_fixture()
now = DateTime.truncate(DateTime.utc_now(), :second)
for i <- 1..5 do
p = product_fixture(%{caliber: caliber, retailer: retailer})
snapshot_fixture(%{product: p, price_per_round_cents: i * 10, recorded_at: now})
end
results = Prices.latest_prices_for_caliber(caliber.id, limit: 3)
assert length(results) == 3
end
end
describe "daily_averages_for_caliber/2" do
test "returns daily avg/min/max of price_per_round_cents" do
caliber = caliber_fixture()
retailer = retailer_fixture()
product = product_fixture(%{caliber: caliber, retailer: retailer})
today = DateTime.truncate(DateTime.utc_now(), :second)
yesterday = DateTime.add(today, -86_400, :second)
snapshot_fixture(%{product: product, price_per_round_cents: 30, recorded_at: today})
snapshot_fixture(%{product: product, price_per_round_cents: 40, recorded_at: today})
snapshot_fixture(%{product: product, price_per_round_cents: 20, recorded_at: yesterday})
results = Prices.daily_averages_for_caliber(caliber.id, days: 7)
assert length(results) == 2
# Results ordered by date ASC
[day1, day2] = results
assert day1.min_ppr == 20
assert day1.max_ppr == 20
assert day2.min_ppr == 30
assert day2.max_ppr == 40
assert day2.avg_ppr == 35
end
end
describe "price_history_for_product/2" do
test "returns all snapshots for a product in date range" do
product = product_fixture()
now = DateTime.truncate(DateTime.utc_now(), :second)
for i <- 0..4 do
snapshot_fixture(%{
product: product,
price_per_round_cents: 30 + i,
recorded_at: DateTime.add(now, -i * 86_400, :second)
})
end
results = Prices.price_history_for_product(product.id, days: 2)
# Should include today, yesterday, and day-before (boundary) = 3 of 5 snapshots
assert length(results) == 3
end
end
describe "price_stats_for_caliber/1" do
test "returns min, max, avg stats for a caliber" do
caliber = caliber_fixture()
retailer = retailer_fixture()
now = DateTime.truncate(DateTime.utc_now(), :second)
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
snapshot_fixture(%{product: product, price_per_round_cents: 20, in_stock: true, recorded_at: now})
snapshot_fixture(%{
product: product,
price_per_round_cents: 40,
in_stock: true,
recorded_at: DateTime.add(now, -3600, :second)
})
snapshot_fixture(%{
product: product,
price_per_round_cents: 30,
in_stock: true,
recorded_at: DateTime.add(now, -7200, :second)
})
stats = Prices.price_stats_for_caliber(caliber.id)
assert stats.current_min == 20
assert stats.all_time_low == 20
assert stats.all_time_high == 40
assert is_integer(stats.thirty_day_avg)
end
test "returns thirty_day_avg based on recent snapshots" do
caliber = caliber_fixture()
retailer = retailer_fixture()
now = DateTime.truncate(DateTime.utc_now(), :second)
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
# Recent snapshots (within 30 days)
snapshot_fixture(%{product: product, price_per_round_cents: 30, in_stock: true, recorded_at: now})
snapshot_fixture(%{
product: product,
price_per_round_cents: 40,
in_stock: true,
recorded_at: DateTime.add(now, -86_400, :second)
})
# Old snapshot (outside 30 days)
snapshot_fixture(%{
product: product,
price_per_round_cents: 100,
in_stock: true,
recorded_at: DateTime.add(now, -40 * 86_400, :second)
})
stats = Prices.price_stats_for_caliber(caliber.id)
# 30-day avg should be (30+40)/2 = 35, not include the 100
assert stats.thirty_day_avg == 35
end
test "returns nil stats when no snapshots exist" do
caliber = caliber_fixture()
stats = Prices.price_stats_for_caliber(caliber.id)
assert stats.current_min == nil
assert stats.all_time_low == nil
assert stats.all_time_high == nil
assert stats.thirty_day_avg == nil
end
end
describe "cheapest_per_caliber/0" do
test "returns min price_per_round for each caliber" do
caliber1 = caliber_fixture(%{name: "9mm", slug: "9mm"})
caliber2 = caliber_fixture(%{name: "5.56", slug: "556"})
retailer = retailer_fixture()
now = DateTime.truncate(DateTime.utc_now(), :second)
p1 = product_fixture(%{caliber: caliber1, retailer: retailer, in_stock: true})
p2 = product_fixture(%{caliber: caliber2, retailer: retailer, in_stock: true})
snapshot_fixture(%{product: p1, price_per_round_cents: 25, in_stock: true, recorded_at: now})
snapshot_fixture(%{product: p1, price_per_round_cents: 30, in_stock: true, recorded_at: now})
snapshot_fixture(%{product: p2, price_per_round_cents: 35, in_stock: true, recorded_at: now})
results = Prices.cheapest_per_caliber()
result_map = Map.new(results, fn r -> {r.caliber_id, r.min_ppr} end)
assert result_map[caliber1.id] == 25
assert result_map[caliber2.id] == 35
end
end
end

View file

@ -0,0 +1,37 @@
defmodule Ammoprices.Scraping.CaliberMatcherTest do
use Ammoprices.DataCase, async: true
import Ammoprices.Fixtures
alias Ammoprices.Scraping.CaliberMatcher
describe "match/2" do
test "matches by caliber name" do
caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", aliases: ["9mm", "9x19"]})
calibers = [caliber]
assert CaliberMatcher.match("Federal 9mm Luger 115gr FMJ", calibers) == caliber
end
test "matches by alias" do
caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", aliases: ["9mm", "9x19"]})
calibers = [caliber]
assert CaliberMatcher.match("Wolf 9x19 124gr FMJ", calibers) == caliber
end
test "returns nil when no match" do
caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", aliases: ["9mm", "9x19"]})
calibers = [caliber]
assert CaliberMatcher.match("Some .45 ACP Ammo", calibers) == nil
end
test "match is case insensitive" do
caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", aliases: ["9mm", "9x19"]})
calibers = [caliber]
assert CaliberMatcher.match("WOLF 9MM LUGER 115GR", calibers) == caliber
end
end
end

View file

@ -0,0 +1,92 @@
defmodule Ammoprices.Scraping.Retailers.LuckyGunnerTest do
use ExUnit.Case, async: true
alias Ammoprices.Scraping.Retailers.LuckyGunner
@fixture_path "test/fixtures/lucky_gunner/9mm.html"
describe "retailer_slug/0" do
test "returns lucky-gunner" do
assert LuckyGunner.retailer_slug() == "lucky-gunner"
end
end
describe "category_url/1" do
test "maps 9mm-luger caliber to correct URL" do
caliber = %{slug: "9mm-luger", category: "handgun"}
assert LuckyGunner.category_url(caliber) == "/handgun/9mm-ammo"
end
test "returns nil for unmapped caliber" do
caliber = %{slug: "unknown-caliber", category: "handgun"}
assert LuckyGunner.category_url(caliber) == nil
end
end
describe "parse_products/1" do
setup do
html = File.read!(@fixture_path)
%{html: html}
end
test "extracts all products from HTML", %{html: html} do
products = LuckyGunner.parse_products(html)
assert length(products) == 3
end
test "extracts product title", %{html: html} do
[first | _] = LuckyGunner.parse_products(html)
assert first.title == "9mm - 115 Grain FMJ - Wolf - 1350 Rounds **STEEL CASES**"
end
test "extracts product URL", %{html: html} do
[first | _] = LuckyGunner.parse_products(html)
assert first.url == "https://www.luckygunner.com/9mm-115-grain-fmj-wolf-1350-rounds"
end
test "extracts price in cents", %{html: html} do
[first | _] = LuckyGunner.parse_products(html)
assert first.price_cents == 27_000
end
test "extracts price per round in cents", %{html: html} do
[first | _] = LuckyGunner.parse_products(html)
assert first.price_per_round_cents == 20
end
test "extracts fractional price per round", %{html: html} do
products = LuckyGunner.parse_products(html)
second = Enum.at(products, 1)
# 21.2¢ → 21 cents (truncated to integer)
assert second.price_per_round_cents == 21
end
test "extracts brand from description", %{html: html} do
[first | _] = LuckyGunner.parse_products(html)
assert first.brand == "Wolf"
end
test "extracts grain weight from description", %{html: html} do
[first | _] = LuckyGunner.parse_products(html)
assert first.grain_weight == 115
end
test "extracts round count from title", %{html: html} do
[first | _] = LuckyGunner.parse_products(html)
assert first.round_count == 1350
end
test "detects casing material from description", %{html: html} do
products = LuckyGunner.parse_products(html)
# First product: steel casings
assert hd(products).casing == "steel"
# Third product: brass casings
assert Enum.at(products, 2).casing == "brass"
end
test "marks products as in stock", %{html: html} do
[first | _] = LuckyGunner.parse_products(html)
assert first.in_stock == true
end
end
end

View file

@ -0,0 +1,77 @@
defmodule Ammoprices.Scraping.Retailers.SgAmmoTest do
use ExUnit.Case, async: true
alias Ammoprices.Scraping.Retailers.SgAmmo
@fixture_path "test/fixtures/sg_ammo/9mm.html"
describe "retailer_slug/0" do
test "returns sgammo" do
assert SgAmmo.retailer_slug() == "sgammo"
end
end
describe "category_url/1" do
test "maps 9mm-luger caliber to correct URL" do
caliber = %{slug: "9mm-luger", category: "handgun"}
assert SgAmmo.category_url(caliber) == "/catalog/pistol-ammo-for-sale/9mm-luger-ammo"
end
test "returns nil for unmapped caliber" do
caliber = %{slug: "unknown", category: "handgun"}
assert SgAmmo.category_url(caliber) == nil
end
end
describe "parse_products/1" do
setup do
html = File.read!(@fixture_path)
%{html: html}
end
test "extracts all products from HTML", %{html: html} do
products = SgAmmo.parse_products(html)
assert length(products) == 3
end
test "extracts product title", %{html: html} do
[first | _] = SgAmmo.parse_products(html)
assert first.title =~ "50 Round Box - 9mm Luger 115 Grain FMJ Ammo by Magtech"
end
test "extracts product URL", %{html: html} do
[first | _] = SgAmmo.parse_products(html)
assert first.url =~ "sgammo.com/product/9mm-luger-ammo/"
end
test "extracts price in cents", %{html: html} do
[first | _] = SgAmmo.parse_products(html)
assert first.price_cents == 1395
end
test "extracts price per round in cents", %{html: html} do
[first | _] = SgAmmo.parse_products(html)
assert first.price_per_round_cents == 28
end
test "extracts round count from title", %{html: html} do
[first | _] = SgAmmo.parse_products(html)
assert first.round_count == 50
end
test "extracts grain weight from title", %{html: html} do
[first | _] = SgAmmo.parse_products(html)
assert first.grain_weight == 115
end
test "extracts brand from title", %{html: html} do
[first | _] = SgAmmo.parse_products(html)
assert first.brand == "Magtech"
end
test "marks in-stock products", %{html: html} do
[first | _] = SgAmmo.parse_products(html)
assert first.in_stock == true
end
end
end

View file

@ -0,0 +1,52 @@
defmodule Ammoprices.Scraping.RunnerTest do
use Ammoprices.DataCase, async: true
import Ammoprices.Fixtures
alias Ammoprices.Catalog
alias Ammoprices.Scraping.Retailers.LuckyGunner
alias Ammoprices.Scraping.Runner
@fixture_html File.read!("test/fixtures/lucky_gunner/9mm.html")
setup 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.html(conn, @fixture_html)
end)
%{retailer: retailer, caliber: caliber}
end
describe "run/2" do
test "creates products and snapshots from scrape", %{caliber: caliber} do
scraper = LuckyGunner
assert {:ok, stats} = Runner.run(scraper, caliber)
assert stats.products_count == 3
assert stats.snapshots_count == 3
products = Catalog.list_products_for_caliber(caliber.id)
assert length(products) == 3
end
test "updates retailer last_scraped_at", %{caliber: caliber} do
scraper = LuckyGunner
{:ok, _stats} = Runner.run(scraper, caliber)
retailer = Catalog.get_retailer_by_slug!("lucky-gunner")
assert retailer.last_scraped_at
end
test "handles scraper returning nil category_url", %{caliber: _caliber} do
caliber = caliber_fixture(%{name: "Unknown", slug: "unknown", category: "handgun"})
scraper = LuckyGunner
assert {:ok, stats} = Runner.run(scraper, caliber)
assert stats.products_count == 0
end
end
end

View file

@ -0,0 +1,28 @@
defmodule Ammoprices.Scraping.ScrapeJobTest do
use Ammoprices.DataCase
use Oban.Testing, repo: Ammoprices.Repo
import Ammoprices.Fixtures
alias Ammoprices.Scraping.ScrapeJob
@fixture_html File.read!("test/fixtures/lucky_gunner/9mm.html")
setup do
retailer_fixture(%{name: "Lucky Gunner", slug: "lucky-gunner", base_url: "https://www.luckygunner.com"})
retailer_fixture(%{name: "SGAmmo", slug: "sgammo", base_url: "https://www.sgammo.com"})
caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun", aliases: ["9mm", "9x19"]})
Req.Test.stub(Ammoprices.Scraping.HttpClient, fn conn ->
Req.Test.html(conn, @fixture_html)
end)
:ok
end
describe "perform/1" do
test "executes scrape for all enabled scrapers and calibers" do
assert :ok = perform_job(ScrapeJob, %{})
end
end
end

View file

@ -0,0 +1,14 @@
defmodule AmmopricesWeb.ErrorHTMLTest do
use AmmopricesWeb.ConnCase, async: true
# Bring render_to_string/4 for testing custom views
import Phoenix.Template, only: [render_to_string: 4]
test "renders 404.html" do
assert render_to_string(AmmopricesWeb.ErrorHTML, "404", "html", []) == "Not Found"
end
test "renders 500.html" do
assert render_to_string(AmmopricesWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
end
end

View file

@ -0,0 +1,12 @@
defmodule AmmopricesWeb.ErrorJSONTest do
use AmmopricesWeb.ConnCase, async: true
test "renders 404" do
assert AmmopricesWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
end
test "renders 500" do
assert AmmopricesWeb.ErrorJSON.render("500.json", %{}) ==
%{errors: %{detail: "Internal Server Error"}}
end
end

View file

@ -0,0 +1,8 @@
defmodule AmmopricesWeb.PageControllerTest do
use AmmopricesWeb.ConnCase
test "GET / redirects to HomeLive", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Ammo Price Tracker"
end
end

View file

@ -0,0 +1,93 @@
defmodule AmmopricesWeb.CaliberLive.ShowTest do
use AmmopricesWeb.ConnCase
import Ammoprices.Fixtures
import Phoenix.LiveViewTest
setup do
caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun"})
retailer = retailer_fixture(%{name: "Lucky Gunner", slug: "lucky-gunner", base_url: "https://www.luckygunner.com"})
%{caliber: caliber, retailer: retailer}
end
describe "CaliberLive.Show" do
test "renders caliber page with title", %{conn: conn, caliber: caliber} do
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
assert has_element?(view, "#caliber-heading")
end
test "renders product listings with stream", %{conn: conn, caliber: caliber, retailer: retailer} do
product = product_fixture(%{caliber: caliber, retailer: retailer, title: "Federal 9mm 115gr"})
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}")
assert has_element?(view, "#products")
assert has_element?(view, "#products [id]")
end
test "shows empty state when no products", %{conn: conn, caliber: caliber} do
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
assert has_element?(view, "#products-empty")
end
test "filters by in-stock", %{conn: conn, caliber: caliber, retailer: retailer} do
in_stock = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true, title: "In Stock Product"})
oos = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: false, title: "Out of Stock Product"})
now = DateTime.truncate(DateTime.utc_now(), :second)
snapshot_fixture(%{product: in_stock, price_per_round_cents: 28, in_stock: true, recorded_at: now})
snapshot_fixture(%{product: oos, price_per_round_cents: 22, in_stock: false, recorded_at: now})
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
# Default is in_stock: true
assert has_element?(view, "#filter-in-stock")
end
test "renders price stats banner", %{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: 25, in_stock: true, recorded_at: now})
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
assert has_element?(view, "#price-stats")
end
test "renders chart container with data attribute", %{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: 30, in_stock: true, recorded_at: now})
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
assert has_element?(view, "#price-chart-container")
assert has_element?(view, "[data-chart-data]")
end
test "renders time range buttons", %{conn: conn, caliber: caliber} do
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
assert has_element?(view, "#range-7d")
assert has_element?(view, "#range-30d")
assert has_element?(view, "#range-90d")
assert has_element?(view, "#range-1y")
assert has_element?(view, "#range-all")
end
test "changes chart range when range button clicked", %{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: 30, in_stock: true, recorded_at: now})
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
assert view |> element("#range-7d") |> render_click()
assert has_element?(view, "#price-chart-container")
end
end
end

View file

@ -0,0 +1,37 @@
defmodule AmmopricesWeb.HomeLiveTest do
use AmmopricesWeb.ConnCase, async: true
import Ammoprices.Fixtures
import Phoenix.LiveViewTest
describe "HomeLive" do
test "renders category sections", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert has_element?(view, "#category-handgun")
assert has_element?(view, "#category-rifle")
assert has_element?(view, "#category-rimfire")
assert has_element?(view, "#category-shotgun")
end
test "renders caliber links", %{conn: conn} do
caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun"})
{:ok, view, _html} = live(conn, "/")
assert has_element?(view, "#caliber-9mm-luger")
end
test "shows cheapest price when snapshots exist", %{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)
snapshot_fixture(%{product: product, price_per_round_cents: 25, in_stock: true, recorded_at: now})
{:ok, _view, html} = live(conn, "/")
assert html =~ "25"
end
end
end

103
test/fixtures/lucky_gunner/9mm.html vendored Normal file
View file

@ -0,0 +1,103 @@
<ul class="products-list">
<li class="item">
<a href="https://www.luckygunner.com/9mm-115-grain-fmj-wolf-1350-rounds" class="product-image">
<img src="https://cdn-secure.luckygunner.com/media/catalog/product/wolf-9mm.jpg" alt="Wolf 9mm 115gr FMJ">
</a>
<div class="product-shop">
<h3 class="product-name">
<a href="https://www.luckygunner.com/9mm-115-grain-fmj-wolf-1350-rounds">
<span>9mm - 115 Grain FMJ - Wolf - 1350 Rounds **STEEL CASES**</span>
</a>
</h3>
<div class="col2-set">
<div class="col-1">
<div class="price-box">
<span class="regular-price">$270.00</span>
</div>
</div>
<div class="col-2">
<p class="cprc">20¢ per round</p>
<p class="availability availability-simple">
<span class="stock-qty">70 </span><span class="in-stock">In Stock</span>
</p>
</div>
</div>
<div class="desc std">
<ul>
<li>Quantity - 50 rounds per box; 27 boxes per case</li>
<li>Manufacturer - Wolf</li>
<li>Bullets - 115 grain full metal jacket (FMJ)</li>
<li>Casings - Berdan-primed copper-washed steel</li>
</ul>
</div>
</div>
</li>
<li class="item">
<a href="https://www.luckygunner.com/9mm-124-grain-fmj-magtech-steel-1000-rounds-steel-cases" class="product-image">
<img src="https://cdn-secure.luckygunner.com/media/catalog/product/magtech-9mm.jpg" alt="Magtech 9mm 124gr FMJ">
</a>
<div class="product-shop">
<h3 class="product-name">
<a href="https://www.luckygunner.com/9mm-124-grain-fmj-magtech-steel-1000-rounds-steel-cases">
<span>9mm - 124 Grain FMJ - Magtech Steel - 1000 Rounds *STEEL CASES*</span>
</a>
</h3>
<div class="col2-set">
<div class="col-1">
<div class="price-box">
<span class="regular-price">$212.00</span>
</div>
</div>
<div class="col-2">
<p class="cprc">21.2¢ per round</p>
<p class="availability availability-simple">
<span class="stock-qty">88 </span><span class="in-stock">In Stock</span>
</p>
</div>
</div>
<div class="desc std">
<ul>
<li>Quantity - 50 rounds per box; 20 boxes per case</li>
<li>Manufacturer - Magtech</li>
<li>Bullets - 124 grain full metal jacket (FMJ)</li>
<li>Casings - Boxer-primed zinc-plated steel</li>
</ul>
</div>
</div>
</li>
<li class="item">
<a href="https://www.luckygunner.com/9mm-115-grain-fmj-cci-blazer-brass-1000-rounds" class="product-image">
<img src="https://cdn-secure.luckygunner.com/media/catalog/product/cci-9mm.jpg" alt="CCI Blazer Brass 9mm 115gr FMJ">
</a>
<div class="product-shop">
<h3 class="product-name">
<a href="https://www.luckygunner.com/9mm-115-grain-fmj-cci-blazer-brass-1000-rounds">
<span>9mm - 115 Grain FMJ - CCI Blazer Brass - 1000 Rounds</span>
</a>
</h3>
<div class="col2-set">
<div class="col-1">
<div class="price-box">
<span class="regular-price">$269.50</span>
</div>
</div>
<div class="col-2">
<p class="cprc">27¢ per round</p>
<p class="availability availability-simple">
<span class="stock-qty">142 </span><span class="in-stock">In Stock</span>
</p>
</div>
</div>
<div class="desc std">
<ul>
<li>Quantity - 50 rounds per box; 20 boxes per case</li>
<li>Manufacturer - CCI</li>
<li>Bullets - 115 grain full metal jacket (FMJ)</li>
<li>Casings - Boxer-primed brass</li>
</ul>
</div>
</div>
</li>
</ul>

76
test/fixtures/sg_ammo/9mm.html vendored Normal file
View file

@ -0,0 +1,76 @@
<table>
<tbody>
<tr class="sgammo-content-product-item product instock product_cat-9mm-luger-ammo">
<td class="sgammo-content-product-item__col-thumbnail">
<img src="https://sgammo.com/wp-content/uploads/magtech-9mm.jpg" alt="">
</td>
<td class="sgammo-content-product-item__col-title">
<h4 class="sgammo-content-product-item__title">
<a href="https://sgammo.com/product/9mm-luger-ammo/50-round-box-9mm-luger-115-grain-fmj-ammo-by-magtech-9a/">
50 Round Box - 9mm Luger 115 Grain FMJ Ammo by Magtech - 9A
</a>
</h4>
<div>SKU: MGT-9A-Box</div>
</td>
<td class="sgammo-content-product-item__col-qty">
120+
</td>
<td class="sgammo-content-product-item__col-price">
<strong class="sgammo-cl-mb025">
<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">$</span>13.95</bdi></span> Each
</strong>
<strong class="sgammo-cl-mb025">20+ @<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">$</span>12.99</bdi></span></strong>
<span>(<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">$</span>0.28</bdi></span> Per Round)</span>
</td>
</tr>
<tr class="sgammo-content-product-item product instock product_cat-9mm-luger-ammo">
<td class="sgammo-content-product-item__col-thumbnail">
<img src="https://sgammo.com/wp-content/uploads/magtech-9mm-case.jpg" alt="">
</td>
<td class="sgammo-content-product-item__col-title">
<h4 class="sgammo-content-product-item__title">
<a href="https://sgammo.com/product/9mm-luger-ammo/1000-round-case-9mm-luger-115-grain-fmj-ammo-by-magtech-9a/">
1000 Round Case - 9mm Luger 115 Grain FMJ Ammo by Magtech - 9A
</a>
</h4>
<div>SKU: MTG-9A-case</div>
</td>
<td class="sgammo-content-product-item__col-qty">
25+
</td>
<td class="sgammo-content-product-item__col-price">
<strong class="sgammo-cl-mb025">
<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">$</span>259.80</bdi></span> Each
</strong>
<strong class="sgammo-cl-mb025">FREE <br>SHIPPING</strong>
<strong class="sgammo-cl-mb025">2+ @<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">$</span>249.80</bdi></span></strong>
<span>(<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">$</span>0.26</bdi></span> Per Round)</span>
</td>
</tr>
<tr class="sgammo-content-product-item product instock product_cat-9mm-luger-ammo">
<td class="sgammo-content-product-item__col-thumbnail">
<img src="https://sgammo.com/wp-content/uploads/winchester-9mm.jpg" alt="">
</td>
<td class="sgammo-content-product-item__col-title">
<h4 class="sgammo-content-product-item__title">
<a href="https://sgammo.com/product/9mm-luger-ammo/100-round-box-9mm-luger-winchester-fmj-115-grain-value-pack-ammo/">
100 Round Box - 9mm Luger Winchester FMJ 115 Grain Value Pack Ammo - USA9MMVP
</a>
</h4>
<div>SKU: USA9MMVP</div>
</td>
<td class="sgammo-content-product-item__col-qty">
50+
</td>
<td class="sgammo-content-product-item__col-price">
<strong class="sgammo-cl-mb025">
<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">$</span>27.95</bdi></span> Each
</strong>
<strong class="sgammo-cl-mb025">10+ @<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">$</span>25.95</bdi></span></strong>
<span>(<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">$</span>0.28</bdi></span> Per Round)</span>
</td>
</tr>
</tbody>
</table>

38
test/support/conn_case.ex Normal file
View file

@ -0,0 +1,38 @@
defmodule AmmopricesWeb.ConnCase do
@moduledoc """
This module defines the test case to be used by
tests that require setting up a connection.
Such tests rely on `Phoenix.ConnTest` and also
import other functionality to make it easier
to build common data structures and query the data layer.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use AmmopricesWeb.ConnCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
use AmmopricesWeb, :verified_routes
import AmmopricesWeb.ConnCase
import Phoenix.ConnTest
import Plug.Conn
# The default endpoint for testing
@endpoint AmmopricesWeb.Endpoint
# Import conveniences for testing with connections
end
end
setup tags do
Ammoprices.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end

60
test/support/data_case.ex Normal file
View file

@ -0,0 +1,60 @@
defmodule Ammoprices.DataCase do
@moduledoc """
This module defines the setup for tests requiring
access to the application's data layer.
You may define functions here to be used as helpers in
your tests.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use Ammoprices.DataCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
alias Ecto.Adapters.SQL.Sandbox
using do
quote do
import Ammoprices.DataCase
import Ecto
import Ecto.Changeset
import Ecto.Query
alias Ammoprices.Repo
end
end
setup tags do
Ammoprices.DataCase.setup_sandbox(tags)
:ok
end
@doc """
Sets up the sandbox based on the test tags.
"""
def setup_sandbox(tags) do
pid = Sandbox.start_owner!(Ammoprices.Repo, shared: not tags[:async])
on_exit(fn -> Sandbox.stop_owner(pid) end)
end
@doc """
A helper that transforms changeset errors into a map of messages.
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
assert "password is too short" in errors_on(changeset).password
assert %{password: ["password is too short"]} = errors_on(changeset)
"""
def errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end

96
test/support/fixtures.ex Normal file
View file

@ -0,0 +1,96 @@
defmodule Ammoprices.Fixtures do
@moduledoc """
Factory functions for test data.
"""
alias Ammoprices.Catalog.Caliber
alias Ammoprices.Catalog.Product
alias Ammoprices.Catalog.Retailer
alias Ammoprices.Prices.PriceSnapshot
alias Ammoprices.Repo
def retailer_fixture(attrs \\ %{}) do
{:ok, retailer} =
%Retailer{}
|> Retailer.changeset(
Map.merge(
%{
name: "Test Retailer",
slug: "test-retailer-#{System.unique_integer([:positive])}",
base_url: "https://example.com"
},
attrs
)
)
|> Repo.insert()
retailer
end
def caliber_fixture(attrs \\ %{}) do
{:ok, caliber} =
%Caliber{}
|> Caliber.changeset(
Map.merge(
%{
name: "9mm Luger",
slug: "9mm-luger-#{System.unique_integer([:positive])}",
category: "handgun",
aliases: ["9mm", "9x19"]
},
attrs
)
)
|> Repo.insert()
caliber
end
def product_fixture(attrs \\ %{}) do
retailer = Map.get_lazy(attrs, :retailer, fn -> retailer_fixture() end)
caliber = Map.get_lazy(attrs, :caliber, fn -> caliber_fixture() end)
{:ok, product} =
%Product{retailer_id: retailer.id, caliber_id: caliber.id}
|> Product.changeset(
Map.merge(
%{
title: "Test Ammo 9mm 115gr FMJ",
url: "/products/test-#{System.unique_integer([:positive])}",
brand: "TestBrand",
grain_weight: 115,
round_count: 50,
casing: "brass",
condition: "new",
in_stock: true,
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
},
Map.drop(attrs, [:retailer, :caliber])
)
)
|> Repo.insert()
product
end
def snapshot_fixture(attrs \\ %{}) do
product = Map.get_lazy(attrs, :product, fn -> product_fixture() end)
{:ok, snapshot} =
%PriceSnapshot{product_id: product.id}
|> PriceSnapshot.changeset(
Map.merge(
%{
price_cents: 1599,
price_per_round_cents: 32,
in_stock: true,
recorded_at: DateTime.truncate(DateTime.utc_now(), :second)
},
Map.delete(attrs, :product)
)
)
|> Repo.insert()
snapshot
end
end

2
test/test_helper.exs Normal file
View file

@ -0,0 +1,2 @@
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Ammoprices.Repo, :manual)