- Fix Oban self-scheduling chain silently dying (remove unique constraint so after block can schedule next job)
- Fix Natchez and TrueShotAmmo extract_price defaulting to 0 for missing price data
- Fix caliber_matcher redundant String.downcase inside Enum.find (pre-downcase outside loop)
- Fix runner.ex double iteration (Enum.map + Enum.count merged into single Enum.reduce)
- Fix price_stats_for_caliber firing 3 queries instead of 2 (merge all_time + thirty_day via FILTER)
- Fix PSA duplicate Floki.find on same selector (merge extract_title + extract_url)
- Fix PubSub broadcast carrying no caliber IDs (now includes IDs; clients skip irrelevant reloads)
- Fix BulkAmmo ^ anchor skipping non-leading round counts
- Fix TargetSportsUSA fragile exact text match 'Add To Cart' (use case-insensitive contains)
- Fix SgAmmo dead destructure {_, _, _} = row
- Fix text_detector brass/nickel detection order
- Fix Natchez GraphQL string interpolation (add escape_gql_string)
- Fix chart data triple Enum.map merged into single reduce
- Change Oban scraping queue workers from 2 to 1 (only one needed with Process.sleep)
629 lines
22 KiB
Elixir
629 lines
22 KiB
Elixir
defmodule AmmopricesWeb.CaliberLive.Show do
|
|
@moduledoc false
|
|
use AmmopricesWeb, :live_view
|
|
|
|
import AmmopricesWeb.PriceComponents
|
|
|
|
alias Ammoprices.Catalog
|
|
alias Ammoprices.Prices
|
|
|
|
@range_days %{
|
|
"7d" => 7,
|
|
"30d" => 30,
|
|
"90d" => 90,
|
|
"1y" => 365,
|
|
"all" => 3650
|
|
}
|
|
|
|
@impl true
|
|
def mount(%{"slug" => slug}, _session, socket) do
|
|
caliber = Catalog.get_caliber_by_slug!(slug)
|
|
|
|
if connected?(socket) do
|
|
Phoenix.PubSub.subscribe(Ammoprices.PubSub, "prices:updated")
|
|
end
|
|
|
|
socket =
|
|
socket
|
|
|> assign(
|
|
page_title: caliber.name,
|
|
caliber: caliber,
|
|
in_stock_filter: true,
|
|
casing_filter: nil,
|
|
grain_weight_filter: nil,
|
|
subsonic_filter: nil,
|
|
chart_range: "30d"
|
|
)
|
|
|> load_filter_options()
|
|
|> load_products()
|
|
|> load_chart_data()
|
|
|> load_price_stats()
|
|
|
|
{:ok, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_stock_filter", _params, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(:in_stock_filter, !socket.assigns.in_stock_filter)
|
|
|> load_products()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("filter_casing", %{"casing" => casing}, socket) do
|
|
current = socket.assigns.casing_filter
|
|
new_value = if current == casing, do: nil, else: casing
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:casing_filter, new_value)
|
|
|> load_products()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("filter_grain_weight", %{"weight" => weight}, socket) do
|
|
current = socket.assigns.grain_weight_filter
|
|
weight_int = String.to_integer(weight)
|
|
new_value = if current == weight_int, do: nil, else: weight_int
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:grain_weight_filter, new_value)
|
|
|> load_products()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_subsonic_filter", _params, socket) do
|
|
current = socket.assigns.subsonic_filter
|
|
new_value = if current == true, do: nil, else: true
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:subsonic_filter, new_value)
|
|
|> load_products()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("change_range", %{"range" => range}, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(:chart_range, range)
|
|
|> load_chart_data()
|
|
|
|
{:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:prices_updated, caliber_ids}, socket) do
|
|
if socket.assigns.caliber.id in caliber_ids do
|
|
socket =
|
|
socket
|
|
|> load_filter_options()
|
|
|> load_products()
|
|
|> load_chart_data()
|
|
|> load_price_stats()
|
|
|
|
{:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
defp load_filter_options(socket) do
|
|
caliber_id = socket.assigns.caliber.id
|
|
|
|
assign(socket,
|
|
available_casings: Catalog.distinct_casings_for_caliber(caliber_id),
|
|
available_grain_weights: Catalog.distinct_grain_weights_for_caliber(caliber_id),
|
|
has_subsonic_products: Catalog.has_subsonic_products?(caliber_id)
|
|
)
|
|
end
|
|
|
|
defp load_products(socket) do
|
|
%{
|
|
caliber: caliber,
|
|
in_stock_filter: in_stock_filter,
|
|
casing_filter: casing_filter,
|
|
grain_weight_filter: grain_weight_filter,
|
|
subsonic_filter: subsonic_filter
|
|
} = socket.assigns
|
|
|
|
opts = [limit: 200]
|
|
opts = if in_stock_filter, do: Keyword.put(opts, :in_stock, true), else: opts
|
|
opts = if casing_filter, do: Keyword.put(opts, :casing, casing_filter), else: opts
|
|
opts = if grain_weight_filter, do: Keyword.put(opts, :grain_weight, grain_weight_filter), else: opts
|
|
opts = if subsonic_filter, do: Keyword.put(opts, :subsonic, true), else: opts
|
|
|
|
snapshots = Prices.latest_prices_for_caliber(caliber.id, opts)
|
|
|
|
product_ids = Enum.map(snapshots, & &1.product_id)
|
|
|
|
products_map =
|
|
if product_ids == [] do
|
|
%{}
|
|
else
|
|
import Ecto.Query
|
|
|
|
from(p in Catalog.Product,
|
|
where: p.id in ^product_ids,
|
|
preload: [:retailer]
|
|
)
|
|
|> Ammoprices.Repo.all()
|
|
|> Map.new(&{&1.id, &1})
|
|
end
|
|
|
|
rows =
|
|
Enum.map(snapshots, fn snap ->
|
|
product = Map.get(products_map, snap.product_id)
|
|
|
|
%{
|
|
id: snap.id,
|
|
product: product,
|
|
price_cents: snap.price_cents,
|
|
price_per_round_cents: snap.price_per_round_cents,
|
|
in_stock: snap.in_stock
|
|
}
|
|
end)
|
|
|
|
stream(socket, :products, rows, reset: true)
|
|
end
|
|
|
|
defp load_chart_data(socket) do
|
|
%{caliber: caliber, chart_range: range} = socket.assigns
|
|
days = Map.get(@range_days, range, 30)
|
|
daily_data = Prices.daily_averages_for_caliber(caliber.id, days: days)
|
|
|
|
{labels, avgs, mins} =
|
|
Enum.reduce(daily_data, {[], [], []}, fn d, {ls, as, ms} ->
|
|
{[Date.to_iso8601(d.date) | ls], [d.avg_ppr | as], [d.min_ppr | ms]}
|
|
end)
|
|
|
|
chart_data = %{
|
|
labels: Enum.reverse(labels),
|
|
avg: Enum.reverse(avgs),
|
|
min: Enum.reverse(mins)
|
|
}
|
|
|
|
assign(socket, chart_data: chart_data, chart_empty?: daily_data == [])
|
|
end
|
|
|
|
defp load_price_stats(socket) do
|
|
stats = Prices.price_stats_for_caliber(socket.assigns.caliber.id)
|
|
assign(socket, :price_stats, stats)
|
|
end
|
|
|
|
defp format_stat_cpr(nil), do: "--"
|
|
|
|
defp format_stat_cpr(cents) do
|
|
"$#{:erlang.float_to_binary(cents / 100, decimals: 2)}"
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash}>
|
|
<div class="space-y-5">
|
|
<%!-- Breadcrumb + Heading --%>
|
|
<div class="space-y-1">
|
|
<nav class="text-xs text-base-content/50">
|
|
<.link navigate={~p"/"} class="hover:text-primary transition-colors">Home</.link>
|
|
<span class="mx-1">/</span>
|
|
<span class="capitalize">{@caliber.category}</span>
|
|
</nav>
|
|
<h1
|
|
id="caliber-heading"
|
|
class="text-2xl sm:text-3xl font-black tracking-tight text-base-content"
|
|
>
|
|
{@caliber.name}
|
|
</h1>
|
|
</div>
|
|
|
|
<%!-- Price Stats Banner --%>
|
|
<div id="price-stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
<div class="rounded-lg border border-base-300 bg-base-200/30 px-4 py-3">
|
|
<div class="text-xs text-base-content/50 uppercase tracking-wider font-medium">
|
|
Current Low
|
|
</div>
|
|
<div class="text-xl font-black tabular-nums text-success mt-0.5">
|
|
{format_stat_cpr(@price_stats.current_min)}
|
|
</div>
|
|
</div>
|
|
<div class="rounded-lg border border-base-300 bg-base-200/30 px-4 py-3">
|
|
<div class="text-xs text-base-content/50 uppercase tracking-wider font-medium">
|
|
30-Day Avg
|
|
</div>
|
|
<div class="text-xl font-black tabular-nums text-base-content mt-0.5">
|
|
{format_stat_cpr(@price_stats.thirty_day_avg)}
|
|
</div>
|
|
</div>
|
|
<div class="rounded-lg border border-base-300 bg-base-200/30 px-4 py-3">
|
|
<div class="text-xs text-base-content/50 uppercase tracking-wider font-medium">
|
|
All-Time Low
|
|
</div>
|
|
<div class="text-xl font-black tabular-nums text-info mt-0.5">
|
|
{format_stat_cpr(@price_stats.all_time_low)}
|
|
</div>
|
|
</div>
|
|
<div class="rounded-lg border border-base-300 bg-base-200/30 px-4 py-3">
|
|
<div class="text-xs text-base-content/50 uppercase tracking-wider font-medium">
|
|
All-Time High
|
|
</div>
|
|
<div class="text-xl font-black tabular-nums text-error mt-0.5">
|
|
{format_stat_cpr(@price_stats.all_time_high)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Price Chart --%>
|
|
<div class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">
|
|
Price History
|
|
</h2>
|
|
<div class="flex gap-1">
|
|
<button
|
|
:for={range <- ~w(7d 30d 90d 1y all)}
|
|
id={"range-#{range}"}
|
|
phx-click="change_range"
|
|
phx-value-range={range}
|
|
class={[
|
|
"px-2.5 py-1 text-xs font-semibold rounded-md transition-all duration-150 cursor-pointer",
|
|
if(@chart_range == range,
|
|
do: "bg-primary text-primary-content",
|
|
else: "text-base-content/50 hover:text-base-content hover:bg-base-200"
|
|
)
|
|
]}
|
|
>
|
|
{String.upcase(range)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="relative h-64 sm:h-80">
|
|
<div
|
|
id="price-chart-container"
|
|
phx-hook=".PriceChart"
|
|
phx-update="ignore"
|
|
data-chart-data={Jason.encode!(@chart_data)}
|
|
class="w-full h-full"
|
|
>
|
|
<canvas id="price-chart" class="w-full h-full"></canvas>
|
|
</div>
|
|
<div
|
|
:if={@chart_empty?}
|
|
id="price-chart-empty"
|
|
class="absolute inset-0 flex items-center justify-center pointer-events-none"
|
|
>
|
|
<p class="text-sm text-base-content/40">
|
|
No price history available for this range yet.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Filters --%>
|
|
<div id="filter-bar" class="flex flex-wrap items-center gap-2">
|
|
<%!-- In Stock toggle --%>
|
|
<button
|
|
id="filter-in-stock"
|
|
phx-click="toggle_stock_filter"
|
|
class={[
|
|
"flex items-center gap-2 px-3 py-1.5 text-xs font-semibold rounded-lg border transition-all duration-150 cursor-pointer",
|
|
if(@in_stock_filter,
|
|
do: "border-success/40 bg-success/10 text-success",
|
|
else: "border-base-300 bg-base-100 text-base-content/60 hover:border-base-content/20"
|
|
)
|
|
]}
|
|
>
|
|
<span class={[
|
|
"inline-block w-1.5 h-1.5 rounded-full",
|
|
if(@in_stock_filter, do: "bg-success", else: "bg-base-content/30")
|
|
]} /> In Stock Only
|
|
</button>
|
|
|
|
<%!-- Subsonic toggle --%>
|
|
<button
|
|
:if={@has_subsonic_products}
|
|
id="filter-subsonic"
|
|
phx-click="toggle_subsonic_filter"
|
|
class={[
|
|
"flex items-center gap-2 px-3 py-1.5 text-xs font-semibold rounded-lg border transition-all duration-150 cursor-pointer",
|
|
if(@subsonic_filter,
|
|
do: "border-warning/40 bg-warning/10 text-warning",
|
|
else: "border-base-300 bg-base-100 text-base-content/60 hover:border-base-content/20"
|
|
)
|
|
]}
|
|
>
|
|
<span class={[
|
|
"inline-block w-1.5 h-1.5 rounded-full",
|
|
if(@subsonic_filter, do: "bg-warning", else: "bg-base-content/30")
|
|
]} /> Subsonic
|
|
</button>
|
|
|
|
<%!-- Casing filter buttons --%>
|
|
<%= if @available_casings != [] do %>
|
|
<span class="text-xs text-base-content/30 mx-1">|</span>
|
|
<button
|
|
:for={casing <- @available_casings}
|
|
id={"filter-casing-#{casing}"}
|
|
phx-click="filter_casing"
|
|
phx-value-casing={casing}
|
|
class={[
|
|
"px-3 py-1.5 text-xs font-semibold rounded-lg border transition-all duration-150 cursor-pointer capitalize",
|
|
if(@casing_filter == casing,
|
|
do: "border-primary/40 bg-primary/10 text-primary",
|
|
else:
|
|
"border-base-300 bg-base-100 text-base-content/60 hover:border-base-content/20"
|
|
)
|
|
]}
|
|
>
|
|
{casing}
|
|
</button>
|
|
<% end %>
|
|
|
|
<%!-- Grain weight filter buttons --%>
|
|
<%= if @available_grain_weights != [] do %>
|
|
<span class="text-xs text-base-content/30 mx-1">|</span>
|
|
<button
|
|
:for={weight <- @available_grain_weights}
|
|
id={"filter-grain-#{weight}"}
|
|
phx-click="filter_grain_weight"
|
|
phx-value-weight={to_string(weight)}
|
|
class={[
|
|
"px-3 py-1.5 text-xs font-semibold rounded-lg border transition-all duration-150 cursor-pointer",
|
|
if(@grain_weight_filter == weight,
|
|
do: "border-primary/40 bg-primary/10 text-primary",
|
|
else:
|
|
"border-base-300 bg-base-100 text-base-content/60 hover:border-base-content/20"
|
|
)
|
|
]}
|
|
>
|
|
{weight}gr
|
|
</button>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%!-- Product Table --%>
|
|
<div class="overflow-x-auto rounded-lg border border-base-300">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="border-b border-base-300 bg-base-200/50">
|
|
<th class="text-left px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider">
|
|
Product
|
|
</th>
|
|
<th class="text-left px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden sm:table-cell">
|
|
Retailer
|
|
</th>
|
|
<th class="text-right px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider">
|
|
CPR
|
|
</th>
|
|
<th class="text-right px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden sm:table-cell">
|
|
Price
|
|
</th>
|
|
<th class="text-center px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden md:table-cell">
|
|
Grain
|
|
</th>
|
|
<th class="text-center px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden md:table-cell">
|
|
Rounds
|
|
</th>
|
|
<th class="text-center px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden md:table-cell">
|
|
Casing
|
|
</th>
|
|
<th class="text-center px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden lg:table-cell">
|
|
Status
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="products" phx-update="stream">
|
|
<tr
|
|
:for={{dom_id, row} <- @streams.products}
|
|
id={dom_id}
|
|
class="border-b border-base-300/50 hover:bg-base-200/30 transition-colors"
|
|
>
|
|
<td class="px-3 py-2.5">
|
|
<div class="flex items-center gap-1.5">
|
|
<a
|
|
href={row.product.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-sm font-medium text-base-content hover:text-primary transition-colors line-clamp-2"
|
|
>
|
|
{row.product.title}
|
|
</a>
|
|
<span
|
|
:if={row.product.subsonic}
|
|
class="shrink-0 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded bg-warning/15 text-warning"
|
|
>
|
|
Sub
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-0.5">
|
|
<span class="sm:hidden text-xs text-base-content/50">
|
|
{row.product.retailer.name}
|
|
</span>
|
|
<span
|
|
:if={row.product.brand}
|
|
class="text-xs text-base-content/40"
|
|
>
|
|
{row.product.brand}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-3 py-2.5 hidden sm:table-cell">
|
|
<span class="text-xs text-base-content/60">{row.product.retailer.name}</span>
|
|
</td>
|
|
<td class="px-3 py-2.5 text-right">
|
|
<.price_per_round
|
|
cents={row.price_per_round_cents}
|
|
class="text-sm font-bold text-success"
|
|
/>
|
|
</td>
|
|
<td class="px-3 py-2.5 text-right hidden sm:table-cell">
|
|
<span class="font-mono tabular-nums text-base-content/80">
|
|
{"$#{:erlang.float_to_binary(row.price_cents / 100, decimals: 2)}"}
|
|
</span>
|
|
</td>
|
|
<td class="px-3 py-2.5 text-center hidden md:table-cell">
|
|
<span :if={row.product.grain_weight} class="text-xs text-base-content/60">
|
|
{row.product.grain_weight}gr
|
|
</span>
|
|
<span :if={!row.product.grain_weight} class="text-xs text-base-content/30">
|
|
—
|
|
</span>
|
|
</td>
|
|
<td class="px-3 py-2.5 text-center hidden md:table-cell">
|
|
<span class="text-xs text-base-content/60">{row.product.round_count || "—"}</span>
|
|
</td>
|
|
<td class="px-3 py-2.5 text-center hidden md:table-cell">
|
|
<span :if={row.product.casing} class="text-xs capitalize text-base-content/60">
|
|
{row.product.casing}
|
|
</span>
|
|
<span :if={!row.product.casing} class="text-xs text-base-content/30">—</span>
|
|
</td>
|
|
<td class="px-3 py-2.5 text-center hidden lg:table-cell">
|
|
<.stock_badge in_stock={row.in_stock} />
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<div
|
|
id="products-empty"
|
|
class="hidden only:block p-8 text-center text-base-content/40 text-sm"
|
|
>
|
|
No products found for this caliber yet. Prices will appear after the next scrape.
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Colocated Chart.js Hook --%>
|
|
<script :type={Phoenix.LiveView.ColocatedHook} name=".PriceChart">
|
|
import Chart from "@/vendor/chart.js"
|
|
|
|
export default {
|
|
mounted() {
|
|
const data = JSON.parse(this.el.dataset.chartData)
|
|
this.chart = this.createChart(data)
|
|
|
|
this.handleEvent("update-chart", ({data}) => {
|
|
this.updateChart(data)
|
|
})
|
|
},
|
|
|
|
createChart(data) {
|
|
const canvas = this.el.querySelector("canvas")
|
|
if (!canvas) return null
|
|
|
|
const ctx = canvas.getContext("2d")
|
|
|
|
return new Chart(ctx, {
|
|
type: "line",
|
|
data: {
|
|
labels: data.labels,
|
|
datasets: [
|
|
{
|
|
label: "Avg CPR",
|
|
data: data.avg,
|
|
borderColor: "oklch(0.65 0.19 160)",
|
|
backgroundColor: "oklch(0.65 0.19 160 / 0.1)",
|
|
borderWidth: 2,
|
|
fill: false,
|
|
tension: 0.3,
|
|
pointRadius: data.labels.length > 60 ? 0 : 3,
|
|
pointHoverRadius: 5
|
|
},
|
|
{
|
|
label: "Min CPR",
|
|
data: data.min,
|
|
borderColor: "oklch(0.7 0.15 200)",
|
|
backgroundColor: "oklch(0.7 0.15 200 / 0.05)",
|
|
borderWidth: 1.5,
|
|
borderDash: [4, 4],
|
|
fill: false,
|
|
tension: 0.3,
|
|
pointRadius: 0,
|
|
pointHoverRadius: 4
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: {
|
|
mode: "index",
|
|
intersect: false
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: true,
|
|
position: "top",
|
|
labels: {
|
|
boxWidth: 12,
|
|
padding: 16,
|
|
usePointStyle: true,
|
|
font: { size: 11 }
|
|
}
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(ctx) {
|
|
const val = ctx.raw
|
|
return ctx.dataset.label + ": $" + (val / 100).toFixed(2)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
type: "category",
|
|
ticks: {
|
|
maxTicksLimit: 8,
|
|
font: { size: 10 }
|
|
},
|
|
grid: { display: false }
|
|
},
|
|
y: {
|
|
ticks: {
|
|
callback: function(val) {
|
|
return "$" + (val / 100).toFixed(2)
|
|
},
|
|
font: { size: 10 }
|
|
},
|
|
grid: { color: "oklch(0.5 0 0 / 0.08)" }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
},
|
|
|
|
updateChart(data) {
|
|
if (!this.chart) {
|
|
this.chart = this.createChart(data)
|
|
return
|
|
}
|
|
this.chart.data.labels = data.labels
|
|
this.chart.data.datasets[0].data = data.avg
|
|
this.chart.data.datasets[0].pointRadius = data.labels.length > 60 ? 0 : 3
|
|
this.chart.data.datasets[1].data = data.min
|
|
this.chart.update()
|
|
},
|
|
|
|
destroyed() {
|
|
if (this.chart) {
|
|
this.chart.destroy()
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
end
|