Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improves the Stats Page #399

Merged
merged 33 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b81fa00
feat: adding when the person joined information inside the Stats page
panoramix360 Jul 22, 2023
c29683e
feat: adds the elapsed_time column
panoramix360 Jul 23, 2023
bed4dc0
feat: adds the last item inserted column
panoramix360 Jul 23, 2023
f9ef079
feat: highlight the column of the current user
panoramix360 Jul 23, 2023
029e4de
feat: creates LiveComponent Table
panoramix360 Jul 31, 2023
520a4bc
feat: using the TableComponent LiveComponent inside the Stats table
panoramix360 Jul 31, 2023
f95e407
feat: sorting column when clicked
panoramix360 Jul 31, 2023
cca5c66
feat: adds the sorting order and renders the arrow on the table
panoramix360 Aug 1, 2023
0e5b3b5
test: change the way stats_live_test test the LiveView, using the dat…
panoramix360 Aug 1, 2023
bec0b5c
test: testing date_time_helper
panoramix360 Aug 2, 2023
5087dae
test: testing the item new functions and table_component
panoramix360 Aug 5, 2023
271899d
test: testing sorting clicks on stats_live table
panoramix360 Aug 5, 2023
8dbe201
test: adding more test cases to item_test
panoramix360 Aug 5, 2023
dbc7853
chore: mix format
panoramix360 Aug 5, 2023
20e8dee
chore: adding docs on new methods
panoramix360 Aug 5, 2023
9276d7c
test: test on pipeline github
panoramix360 Aug 5, 2023
14350a1
test: removing test for total timers because is creating different re…
panoramix360 Aug 5, 2023
eff20d0
chore: Removing warning.
LuchoTurtle Aug 6, 2023
24ff78c
doc: adding first section to explain the sorting and more
panoramix360 Aug 10, 2023
99918d5
test: modify item_test to test without sending the sort_order
panoramix360 Aug 10, 2023
06696e4
doc: finish to add the text to help the dev add the new columns
panoramix360 Aug 15, 2023
7241b82
doc: finish the highlight me section
panoramix360 Aug 15, 2023
fc52a63
doc: finish the creating a livecomponent table section
panoramix360 Aug 15, 2023
43f2306
doc: finish the creating the sorting mechanism section
panoramix360 Aug 16, 2023
a2e399c
chore: pr review - change format
panoramix360 Aug 20, 2023
b3a218e
split Stats tests into separate file for maintainability #397
nelsonic Aug 21, 2023
d2fc733
create dedicated lib/app/stats.ex for metrics/stats related queries #397
nelsonic Aug 21, 2023
1e6e354
add tests for validate_sort_column/1 and validate_order/1 functions a…
nelsonic Aug 21, 2023
a01cf9a
✂️ remove stats sorting section from BUILDIT.md as already published …
nelsonic Aug 21, 2023
5efa83b
revert heading numbering update in BUILDIT.md #350
nelsonic Aug 21, 2023
8d6fab8
Link to https://dwyl.github.io/book/mvp/20-stats.html from stats sect…
nelsonic Aug 21, 2023
2a8c104
add tests for SQL injection for validate_sort_column/1 and validate_o…
nelsonic Aug 21, 2023
43f34d3
fix: Updating `BUILDIT.md` with note and mix.lock file. #399
LuchoTurtle Aug 21, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions BUILDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4255,7 +4255,7 @@ We will do this shortly!
But first, let's implement `Item.person_with_item_and_timer_count()`.


In `lib/app/item.ex`,
In `lib/app/stats.ex`,
add the following function.

```elixir
Expand All @@ -4272,7 +4272,6 @@ add the following function.

Ecto.Adapters.SQL.query!(Repo, sql)
|> map_columns_to_values()

end
```

Expand Down Expand Up @@ -4573,7 +4572,7 @@ The *last* thing we need to do
is to add a test for the `person_with_item_and_timer_count/0`
function that was implemented inside `lib/app/item.ex`.

Open `test/app/item_test.exs`
Open `test/app/stats_test.exs`
and add this test.

```elixir
Expand Down Expand Up @@ -4615,6 +4614,10 @@ when creating `timers` or `items`.

![stats_final](https://user-images.githubusercontent.com/17494745/211345854-c541d21c-4289-4576-8fcf-c3b89251ed02.gif)


> **Note**: more stats docs in:
[dwyl.github.io/book/mvp/stats](https://dwyl.github.io/book/mvp/20-stats.html)

# 13. Tracking changes of `items` in database

Tracking changes that each `item` is subjected to
Expand Down Expand Up @@ -4884,6 +4887,12 @@ in a simple table.
Let's roll!


> ![NOTE]
> If you want to see an upgraded version with more columns and sortable columns,
> do check our [`dwyl/book`](https://dwyl.github.io/book/mvp/20-stats.html).
> It has a run-through of a different stats page that might interest you 😊.


## 14.1 Adding new `LiveView` page in `/stats`


Expand Down
18 changes: 18 additions & 0 deletions lib/app/date_time_helper.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
defmodule App.DateTimeHelper do
require Decimal
alias Timex.{Duration}

def format_date(date) do
Calendar.strftime(date, "%m/%d/%Y %H:%M:%S")
end

def format_duration(nil), do: ""

def format_duration(seconds) when Decimal.is_decimal(seconds) do
duration = seconds |> Decimal.to_integer() |> Duration.from_seconds()

Timex.format_duration(duration, :humanized)
end

def format_duration(_seconds), do: ""
end
27 changes: 0 additions & 27 deletions lib/app/item.ex
Original file line number Diff line number Diff line change
Expand Up @@ -224,33 +224,6 @@ defmodule App.Item do
end)
end

@doc """
`person_with_item_and_timer_count/0` returns a list of number of timers and items per person.
Used mainly for metric-tracking purposes.

## Examples

iex> person_with_item_and_timer_count()
[
%{name: nil, num_items: 3, num_timers: 8, person_id: 0}
%{name: username, num_items: 1, num_timers: 3, person_id: 1}
]
"""
def person_with_item_and_timer_count() do
sql = """
SELECT i.person_id,
COUNT(distinct i.id) AS "num_items",
COUNT(distinct t.id) AS "num_timers"
FROM items i
LEFT JOIN timers t ON t.item_id = i.id
GROUP BY i.person_id
ORDER BY i.person_id
"""

Ecto.Adapters.SQL.query!(Repo, sql)
|> map_columns_to_values()
end

@doc """
`map_columns_to_values/1` takes an Ecto SQL query result
which has the List of columns and rows separate
Expand Down
113 changes: 113 additions & 0 deletions lib/app/stats.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
defmodule App.Stats do
alias App.Repo
require Logger

@doc """
`person_with_item_and_timer_count/2` returns a list of number of timers and items per person
sorted by column and order
Used mainly for metric-tracking purposes.

## Sample:

App.Stats.person_with_item_and_timer_count()
[
%{name: nil, num_items: 3, num_timers: 8, person_id: 0},
%{name: "username", num_items: 1, num_timers: 3, person_id: 1}
]

App.Stats.person_with_item_and_timer_count(:person_id, :desc)
[
%{name: "username", num_items: 1, num_timers: 3, person_id: 1},
%{name: nil, num_items: 3, num_timers: 8, person_id: 0}
]
"""
def person_with_item_and_timer_count(
sort_column \\ :person_id,
sort_order \\ :asc
) do
sort_column = to_string(sort_column)
sort_order = to_string(sort_order)

sort_column =
if validate_sort_column(sort_column), do: sort_column, else: "person_id"

sort_order = if validate_order(sort_order), do: sort_order, else: "asc"

sql = """
SELECT i.person_id,
COUNT(distinct i.id) AS "num_items",
COUNT(distinct t.id) AS "num_timers",
MIN(i.inserted_at) AS "first_inserted_at",
MAX(i.inserted_at) AS "last_inserted_at",
SUM(EXTRACT(EPOCH FROM (t.stop - t.start))) AS "total_timers_in_seconds"
FROM items i
LEFT JOIN timers t ON t.item_id = i.id
GROUP BY i.person_id
ORDER BY #{sort_column} #{sort_order}
"""

Ecto.Adapters.SQL.query!(Repo, sql)
|> map_columns_to_values()
end

@doc """
`map_columns_to_values/1` takes an Ecto SQL query result
which has the List of columns and rows separate
and returns a List of Maps where the keys are the column names and values the data.

Sadly, Ecto returns rows without column keys so we have to map them manually:
ref: https://groups.google.com/g/elixir-ecto/c/0cubhSd3QS0/m/DLdQsFrcBAAJ
"""
def map_columns_to_values(res) do
Enum.map(res.rows, fn row ->
Enum.zip(res.columns, row)
|> Map.new()
|> AtomicMap.convert(safe: false)
end)
end

@doc """
`validate_sort_column/1` validates the column name is in items columns
to make sure it's a valid column passed.

## Examples

iex> App.Stats.validate_sort_column("person_id")
true

iex> App.Stats.validate_sort_column(:invalid)
false

# Avoid ";" character used SQL Injection: e.g: "; DROP TABLE items"
iex> App.Stats.validate_sort_column(";")
false
"""
def validate_sort_column(column) do
Enum.member?(
~w(person_id num_items num_timers first_inserted_at last_inserted_at total_timers_in_seconds),
column
)
end

@doc """
`validate_order/1` validates the ordering is one of `asc` or `desc`

## Examples

iex> App.Stats.validate_order("asc")
true

iex> App.Stats.validate_order(:invalid)
false

# Avoid common SQL injection attacks:
iex> App.Stats.validate_order("OR 1=1")
false
"""
def validate_order(order) do
Enum.member?(
~w(asc desc),
order
)
end
end
11 changes: 11 additions & 0 deletions lib/app_web/live/components/table_component.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule AppWeb.TableComponent do
use Phoenix.LiveComponent

def render(assigns) do
Phoenix.View.render(
AppWeb.TableComponentView,
"table_component.html",
assigns
)
end
end
49 changes: 46 additions & 3 deletions lib/app_web/live/stats_live.ex
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
defmodule AppWeb.StatsLive do
require Logger
use AppWeb, :live_view
alias App.Item
alias App.{Stats, DateTimeHelper}
alias Phoenix.Socket.Broadcast

# run authentication on mount
on_mount(AppWeb.AuthController)

@stats_topic "stats"

defp get_person_id(assigns), do: assigns[:person][:id] || 0
nelsonic marked this conversation as resolved.
Show resolved Hide resolved

@impl true
def mount(_params, _session, socket) do
# subscribe to the channel
if connected?(socket), do: AppWeb.Endpoint.subscribe(@stats_topic)

metrics = Item.person_with_item_and_timer_count()
person_id = get_person_id(socket.assigns)
metrics = Stats.person_with_item_and_timer_count()

{:ok,
assign(socket,
metrics: metrics
person_id: person_id,
metrics: metrics,
sort_column: :person_id,
sort_order: :asc
)}
end

Expand Down Expand Up @@ -60,6 +66,29 @@ defmodule AppWeb.StatsLive do
end
end

@impl true
def handle_event("sort", %{"key" => key}, socket) do
sort_column =
key
|> String.to_atom()

sort_order =
if socket.assigns.sort_column == sort_column do
toggle_sort_order(socket.assigns.sort_order)
else
:asc
end

metrics = Stats.person_with_item_and_timer_count(sort_column, sort_order)

{:noreply,
assign(socket,
metrics: metrics,
sort_column: sort_column,
sort_order: sort_order
)}
end

def add_row(row, payload, key) do
row =
if row.person_id == payload.person_id do
Expand All @@ -74,4 +103,18 @@ defmodule AppWeb.StatsLive do
def person_link(person_id) do
"https://auth.dwyl.com/people/#{person_id}"
end

def format_date(date) do
DateTimeHelper.format_date(date)
end

def format_seconds(seconds) do
DateTimeHelper.format_duration(seconds)
end

def is_highlighted_person?(metric, person_id),
do: metric.person_id == person_id

defp toggle_sort_order(:asc), do: :desc
defp toggle_sort_order(:desc), do: :asc
end
Loading