From ba8d7988b3a1aff9ae7438a56b95d7d78156fbe6 Mon Sep 17 00:00:00 2001 From: shibao Date: Wed, 9 Nov 2022 21:04:57 -0500 Subject: [PATCH] add graph to range page --- CHANGELOG.md | 1 + assets/js/app.js | 3 +- assets/js/shot_log_chart.js | 83 +++++++++++++++++++ assets/package-lock.json | 44 ++++++++++ assets/package.json | 5 +- lib/cannery/activity_log/shot_group.ex | 4 +- lib/cannery/tags.ex | 14 ---- lib/cannery_web/live/range_live/index.ex | 56 ++++++++++++- .../live/range_live/index.html.heex | 16 ++++ lib/cannery_web/live/tag_live/index.ex | 4 +- lib/cannery_web/views/view_helpers.ex | 14 ++++ 11 files changed, 221 insertions(+), 23 deletions(-) create mode 100644 assets/js/shot_log_chart.js diff --git a/CHANGELOG.md b/CHANGELOG.md index aff1788..fcaa6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Make container show page a bit more compact - Make container show page filter used-up ammo - Forgot to add the logo as the favicon whoops +- Add graph to range page - Update project dependencies # v0.5.4 diff --git a/assets/js/app.js b/assets/js/app.js index dfb8827..962985e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -26,6 +26,7 @@ import { Socket } from 'phoenix' import { LiveSocket } from 'phoenix_live_view' import topbar from '../vendor/topbar' import MaintainAttrs from './maintain_attrs' +import ShotLogChart from './shot_log_chart' import Alpine from 'alpinejs' const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content') @@ -36,7 +37,7 @@ const liveSocket = new LiveSocket('/live', Socket, { } }, params: { _csrf_token: csrfToken }, - hooks: { MaintainAttrs } + hooks: { MaintainAttrs, ShotLogChart } }) // alpine.js diff --git a/assets/js/shot_log_chart.js b/assets/js/shot_log_chart.js new file mode 100644 index 0000000..bbf8143 --- /dev/null +++ b/assets/js/shot_log_chart.js @@ -0,0 +1,83 @@ +import { Chart, Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale } from 'chart.js' +import 'chartjs-adapter-date-fns' +Chart.register(Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale) + +export default { + initalizeChart (el) { + const data = JSON.parse(el.dataset.chartData) + + this.el.chart = new Chart(el, { + type: 'line', + data: { + datasets: [{ + label: el.dataset.label, + data: data.map(({ date, count, labels }) => ({ + labels, + x: date, + y: count + })), + backgroundColor: el.dataset.color, + borderColor: el.dataset.color, + fill: true, + borderWidth: 4 + }] + }, + options: { + elements: { + point: { + radius: 7, + hoverRadius: 10 + } + }, + plugins: { + legend: { + position: 'bottom', + labels: { + padding: 20 + } + }, + tooltip: { + displayColors: false, + callbacks: { + label: ({ raw: { labels } }) => labels + } + } + }, + scales: { + y: { + beginAtZero: true, + stacked: true, + grace: '15%', + ticks: { + padding: 15 + } + }, + x: { + type: 'time', + time: { + unit: 'day' + } + } + }, + transitions: { + show: { + animations: { + x: { + from: 0 + } + } + }, + hide: { + animations: { + x: { + to: 0 + } + } + } + } + } + }) + }, + mounted () { this.initalizeChart(this.el) }, + updated () { this.initalizeChart(this.el) } +} diff --git a/assets/package-lock.json b/assets/package-lock.json index 2093e27..401d824 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -8,6 +8,9 @@ "dependencies": { "@fortawesome/fontawesome-free": "^6.1.1", "alpinejs": "^3.10.2", + "chart.js": "^3.9.1", + "chartjs-adapter-date-fns": "^2.0.0", + "date-fns": "^2.29.3", "phoenix": "file:../deps/phoenix", "phoenix_html": "file:../deps/phoenix_html", "phoenix_live_view": "file:../deps/phoenix_live_view", @@ -3166,6 +3169,19 @@ "node": ">=4" } }, + "node_modules/chart.js": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.9.1.tgz", + "integrity": "sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==" + }, + "node_modules/chartjs-adapter-date-fns": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz", + "integrity": "sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw==", + "peerDependencies": { + "chart.js": "^3.0.0" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -3850,6 +3866,18 @@ "node": ">=8.0.0" } }, + "node_modules/date-fns": { + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", + "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==", + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -12537,6 +12565,17 @@ "supports-color": "^5.3.0" } }, + "chart.js": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.9.1.tgz", + "integrity": "sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==" + }, + "chartjs-adapter-date-fns": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz", + "integrity": "sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw==", + "requires": {} + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -13025,6 +13064,11 @@ "css-tree": "^1.1.2" } }, + "date-fns": { + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", + "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==" + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/assets/package.json b/assets/package.json index d6cbdaa..6a1a9d5 100644 --- a/assets/package.json +++ b/assets/package.json @@ -2,7 +2,7 @@ "repository": {}, "description": " ", "license": "MIT", - "engines":{ + "engines": { "node": "18.12.1", "npm": "8.19.2" }, @@ -15,6 +15,9 @@ "dependencies": { "@fortawesome/fontawesome-free": "^6.1.1", "alpinejs": "^3.10.2", + "chart.js": "^3.9.1", + "chartjs-adapter-date-fns": "^2.0.0", + "date-fns": "^2.29.3", "phoenix": "file:../deps/phoenix", "phoenix_html": "file:../deps/phoenix_html", "phoenix_live_view": "file:../deps/phoenix_live_view", diff --git a/lib/cannery/activity_log/shot_group.ex b/lib/cannery/activity_log/shot_group.ex index f81c1fb..d2d1461 100644 --- a/lib/cannery/activity_log/shot_group.ex +++ b/lib/cannery/activity_log/shot_group.ex @@ -58,7 +58,7 @@ defmodule Cannery.ActivityLog.ShotGroup do |> cast(attrs, [:count, :notes, :date]) |> validate_number(:count, greater_than: 0) |> validate_create_shot_group_count(ammo_group) - |> validate_required([:count, :ammo_group_id, :user_id]) + |> validate_required([:count, :date, :ammo_group_id, :user_id]) end def create_changeset(shot_group, _invalid_user, _invalid_ammo_group, attrs) do @@ -90,7 +90,7 @@ defmodule Cannery.ActivityLog.ShotGroup do shot_group |> cast(attrs, [:count, :notes, :date]) |> validate_number(:count, greater_than: 0) - |> validate_required([:count]) + |> validate_required([:count, :date]) |> validate_update_shot_group_count(shot_group, user) end diff --git a/lib/cannery/tags.ex b/lib/cannery/tags.ex index 91069e3..03b9da6 100644 --- a/lib/cannery/tags.ex +++ b/lib/cannery/tags.ex @@ -120,18 +120,4 @@ defmodule Cannery.Tags do """ @spec delete_tag!(Tag.t(), User.t()) :: Tag.t() def delete_tag!(%Tag{user_id: user_id} = tag, %User{id: user_id}), do: tag |> Repo.delete!() - - @doc """ - Get a random tag bg_color in `#ffffff` hex format - - ## Examples - - iex> random_color() - "#cc0066" - """ - @spec random_bg_color() :: <<_::7>> - def random_bg_color do - ["#cc0066", "#ff6699", "#6666ff", "#0066cc", "#00cc66", "#669900", "#ff9900", "#996633"] - |> Enum.random() - end end diff --git a/lib/cannery_web/live/range_live/index.ex b/lib/cannery_web/live/range_live/index.ex index e3c5329..29c049c 100644 --- a/lib/cannery_web/live/range_live/index.ex +++ b/lib/cannery_web/live/range_live/index.ex @@ -88,17 +88,67 @@ defmodule CanneryWeb.RangeLive.Index do shot_groups |> Enum.map(fn shot_group -> shot_group |> get_row_data_for_shot_group(columns) end) + chart_data = + shot_groups + |> Enum.map(fn shot_group -> + shot_group + |> get_chart_data_for_shot_group([:name, :count, :notes, :date]) + end) + |> Enum.sort_by(fn %{date: date} -> date end, Date) + socket - |> assign(ammo_groups: ammo_groups, columns: columns, rows: rows, shot_groups: shot_groups) + |> assign( + ammo_groups: ammo_groups, + columns: columns, + rows: rows, + chart_data: chart_data, + shot_groups: shot_groups + ) end - @spec get_row_data_for_shot_group(ShotGroup.t(), [map()]) :: [map()] + @spec get_chart_data_for_shot_group(ShotGroup.t(), keys :: [atom()]) :: map() + defp get_chart_data_for_shot_group(shot_group, keys) do + shot_group = shot_group |> Repo.preload(ammo_group: :ammo_type) + + labels = + if shot_group.notes do + [gettext("Notes: %{notes}", notes: shot_group.notes)] + else + [] + end + + labels = [ + gettext( + "Name: %{name}", + name: shot_group.ammo_group.ammo_type.name + ), + gettext( + "Rounds shot: %{count}", + count: shot_group.count + ) + | labels + ] + + keys + |> Map.new(fn key -> + value = + case key do + :name -> shot_group.ammo_group.ammo_type.name + key -> shot_group |> Map.get(key) + end + + {key, value} + end) + |> Map.put(:labels, labels) + end + + @spec get_row_data_for_shot_group(ShotGroup.t(), [map()]) :: map() defp get_row_data_for_shot_group(%{date: date} = shot_group, columns) do shot_group = shot_group |> Repo.preload(ammo_group: :ammo_type) assigns = %{shot_group: shot_group} columns - |> Enum.into(%{}, fn %{key: key} -> + |> Map.new(fn %{key: key} -> value = case key do :name -> diff --git a/lib/cannery_web/live/range_live/index.html.heex b/lib/cannery_web/live/range_live/index.html.heex index 311bbbc..390a370 100644 --- a/lib/cannery_web/live/range_live/index.html.heex +++ b/lib/cannery_web/live/range_live/index.html.heex @@ -53,11 +53,27 @@ <%= gettext("Shot log") %> + + <%= dgettext("errors", "Your browser does not support the canvas element.") %> + + <.live_component module={CanneryWeb.Components.TableComponent} id="shot_groups_index_table" columns={@columns} rows={@rows} + initial_key={:date} + initial_sort_mode={:desc} /> <% end %> diff --git a/lib/cannery_web/live/tag_live/index.ex b/lib/cannery_web/live/tag_live/index.ex index fbd1dee..8159257 100644 --- a/lib/cannery_web/live/tag_live/index.ex +++ b/lib/cannery_web/live/tag_live/index.ex @@ -6,7 +6,7 @@ defmodule CanneryWeb.TagLive.Index do use CanneryWeb, :live_view import CanneryWeb.Components.TagCard alias Cannery.{Tags, Tags.Tag} - alias CanneryWeb.Endpoint + alias CanneryWeb.{Endpoint, ViewHelpers} @impl true def mount(_params, _session, socket), do: {:ok, socket |> display_tags()} @@ -25,7 +25,7 @@ defmodule CanneryWeb.TagLive.Index do defp apply_action(socket, :new, _params) do socket |> assign(:page_title, gettext("New Tag")) - |> assign(:tag, %Tag{bg_color: Tags.random_bg_color(), text_color: "#ffffff"}) + |> assign(:tag, %Tag{bg_color: ViewHelpers.random_color(), text_color: "#ffffff"}) end defp apply_action(socket, :index, _params) do diff --git a/lib/cannery_web/views/view_helpers.ex b/lib/cannery_web/views/view_helpers.ex index cfd7f6d..c69d965 100644 --- a/lib/cannery_web/views/view_helpers.ex +++ b/lib/cannery_web/views/view_helpers.ex @@ -115,4 +115,18 @@ defmodule CanneryWeb.ViewHelpers do """ end + + @doc """ + Get a random color in `#ffffff` hex format + + ## Examples + + iex> random_color() + "#cc0066" + """ + @spec random_color() :: <<_::7>> + def random_color do + ["#cc0066", "#ff6699", "#6666ff", "#0066cc", "#00cc66", "#669900", "#ff9900", "#996633"] + |> Enum.random() + end end