ML-200: switch to a vertical chart
This commit is contained in:
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- cloud
|
||||
created_date: "2026-05-31 18:02"
|
||||
updated_date: "2026-05-31 18:50"
|
||||
updated_date: "2026-05-31 18:59"
|
||||
labels: []
|
||||
dependencies: []
|
||||
documentation:
|
||||
@@ -17,7 +17,7 @@ ordinal: 33000
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
|
||||
In the stats page, right before Scrobble Activity, add a horizontal bar chart that shows the last 30 days of scrobble activity as the count of scrobbled tracks per day.
|
||||
In the stats page, right before Scrobble Activity, add a vertical bar chart that shows the last 30 days of scrobble activity as the count of scrobbled tracks per day.
|
||||
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
@@ -168,6 +168,10 @@ No manual production changes are expected.
|
||||
|
||||
Review-fix pass: strengthen Stats LiveView tests for the daily scrobble chart by (1) asserting the rendered row count is tied to the expected date label instead of matching any descendant text, and (2) adding a connected LiveView PubSub refresh test that mounts the page, inserts a new unique scrobble afterwards via `ListeningStats.update/1`, and verifies the daily chart updates without reload. Then run focused tests and formatting.
|
||||
|
||||
Requested UI revision: add a reusable `vertical_bar_chart/1` component to `MusicLibraryWeb.ChartComponents` using CSS Grid and paired light/dark styling, then switch the Daily Scrobbles widget in `StatsLive.Index` from `horizontal_bar_chart/1` to the new component. Preserve existing label/value functions and update LiveView tests only if the DOM structure changes. Verify with focused tests, formatting, Credo, and browser preview on port 4003.
|
||||
|
||||
Small visual refinement: update `vertical_bar_chart/1` day labels from 45-degree angled labels to 90-degree rotated labels centered below each column, then rerun focused tests/formatting and quick browser verification.
|
||||
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
@@ -184,6 +188,14 @@ Review fixes complete: the LiveView test now pairs the expected date label with
|
||||
|
||||
Credo follow-up: replaced `length(...) == literal` assertions in `daily_scrobble_counts/1` tests with `Enum.count_until/2`; full `mix credo --strict` now passes.
|
||||
|
||||
User reviewed the horizontal chart and requested replacing it with a vertical bar chart component for the daily scrobble widget.
|
||||
|
||||
User requested the vertical chart day labels rotate 90 degrees and align centered under each column.
|
||||
|
||||
Vertical label refinement follow-up: labels are cropped after rotating to 90 degrees, so move the rotated label baseline down within its slot and verify again.
|
||||
|
||||
Moved the 90-degree day labels down and centered them under each column by giving the rotated label its own larger relative slot and centering with absolute positioning; focused tests, compile, format check, and component Credo pass.
|
||||
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
@@ -192,7 +204,7 @@ Credo follow-up: replaced `length(...) == literal` assertions in `daily_scrobble
|
||||
|
||||
## What changed
|
||||
|
||||
Added a "Daily Scrobbles" horizontal bar chart to the Stats page showing scrobble count per day for the last 30 local calendar days, positioned immediately before the existing Scrobble Activity section.
|
||||
Added a "Daily Scrobbles" vertical bar chart to the Stats page showing scrobble count per day for the last 30 local calendar days, positioned immediately before the existing Scrobble Activity section.
|
||||
|
||||
### New context function (`MusicLibrary.ListeningStats.daily_scrobble_counts/1`)
|
||||
|
||||
@@ -205,7 +217,8 @@ Added a "Daily Scrobbles" horizontal bar chart to the Stats page showing scrobbl
|
||||
### Stats page widget (`MusicLibraryWeb.StatsLive.Index`)
|
||||
|
||||
- Section titled "Daily Scrobbles" (gettext-wrapped), wrapped in `#daily-scrobble-counts`
|
||||
- Reuses `ChartComponents.horizontal_bar_chart/1` with emerald bars and local date labels (`%b %d`)
|
||||
- Uses new `ChartComponents.vertical_bar_chart/1` with emerald bars and local date labels (`%b %d`)
|
||||
- Day labels are rotated 90 degrees and centered below each column
|
||||
- Refreshes through the existing `assign_scrobble_activity/1` path when PubSub receives `%{track_count: n}` for nonzero imports
|
||||
|
||||
### Tests
|
||||
@@ -216,11 +229,12 @@ Added a "Daily Scrobbles" horizontal bar chart to the Stats page showing scrobbl
|
||||
### Verification
|
||||
|
||||
- `mix test test/music_library/listening_stats_test.exs test/music_library_web/live/stats_live/index_test.exs` — 65 passed
|
||||
- `mix format --check-formatted lib/music_library/listening_stats.ex lib/music_library_web/live/stats_live/index.ex test/music_library/listening_stats_test.exs test/music_library_web/live/stats_live/index_test.exs`
|
||||
- `mix test test/music_library_web/live/stats_live/index_test.exs` — 15 passed after vertical-label refinement
|
||||
- `mix format --check-formatted lib/music_library_web/components/chart_components.ex`
|
||||
- `mix compile --warnings-as-errors`
|
||||
- `mix gettext.extract --check-up-to-date`
|
||||
- `mix credo --strict`
|
||||
- Browser preview on port 4003 verified placement, 30 rendered chart bars/labels, light/dark appearance, and no console errors
|
||||
- `mix credo --strict` / `mix credo --strict lib/music_library_web/components/chart_components.ex`
|
||||
- Browser preview on port 4003 verified placement, 30 rendered chart bars/labels, responsive horizontal scrolling, light/dark appearance, and no console errors
|
||||
|
||||
### Risks / follow-ups
|
||||
|
||||
|
||||
@@ -75,6 +75,77 @@ defmodule MusicLibraryWeb.ChartComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a vertical bar chart using CSS Grid.
|
||||
|
||||
Columns flow horizontally, use a fixed-height plotting area, and scroll on
|
||||
narrow screens while keeping labels and values visible.
|
||||
|
||||
## Examples
|
||||
|
||||
<.vertical_bar_chart
|
||||
data={[{"May 01", 5}, {"May 02", 3}]}
|
||||
label_fn={&elem(&1, 0)}
|
||||
value_fn={&elem(&1, 1)}
|
||||
color_class="bg-emerald-500 dark:bg-emerald-400"
|
||||
/>
|
||||
"""
|
||||
attr :data, :list, required: true
|
||||
attr :label_fn, :any, required: true
|
||||
attr :value_fn, :any, required: true
|
||||
attr :color_class, :string, required: true
|
||||
attr :class, :string, default: ""
|
||||
attr :datum_click, :any, default: nil, doc: "the function for handling phx-click on each datum"
|
||||
|
||||
def vertical_bar_chart(assigns) when assigns.data != [] do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:max_value, max_value(assigns.data, assigns.value_fn))
|
||||
|
||||
~H"""
|
||||
<div class={["w-full overflow-x-auto p-4", @class]}>
|
||||
<div class="grid min-w-full grid-flow-col auto-cols-[minmax(1.75rem,1fr)] items-end gap-2">
|
||||
<%= for datum <- @data do %>
|
||||
<% percentage = bar_percentage(@value_fn.(datum), @max_value) %>
|
||||
<% label = to_string(@label_fn.(datum)) %>
|
||||
|
||||
<%= if @datum_click do %>
|
||||
<div
|
||||
class="group flex h-72 cursor-pointer flex-col items-center justify-end gap-2"
|
||||
phx-click={@datum_click.(datum)}
|
||||
>
|
||||
<.vertical_bar_column
|
||||
label={label}
|
||||
percentage={percentage}
|
||||
value={@value_fn.(datum)}
|
||||
color_class={@color_class}
|
||||
grouped
|
||||
/>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex h-72 flex-col items-center justify-end gap-2">
|
||||
<.vertical_bar_column
|
||||
label={label}
|
||||
percentage={percentage}
|
||||
value={@value_fn.(datum)}
|
||||
color_class={@color_class}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def vertical_bar_chart(assigns) do
|
||||
~H"""
|
||||
<div class={["w-full", @class]}>
|
||||
{gettext("No data available")}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :percentage, :float, required: true
|
||||
attr :value, :any, required: true
|
||||
@@ -110,6 +181,49 @@ defmodule MusicLibraryWeb.ChartComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :percentage, :float, required: true
|
||||
attr :value, :any, required: true
|
||||
attr :color_class, :string, required: true
|
||||
attr :grouped, :boolean, default: false
|
||||
|
||||
defp vertical_bar_column(assigns) do
|
||||
~H"""
|
||||
<div class="shrink-0 text-xs font-semibold text-zinc-500 dark:text-zinc-400">
|
||||
{@value}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-36 w-full shrink-0 items-end rounded bg-zinc-200 dark:bg-zinc-700"
|
||||
title={"#{@label}: #{@value}"}
|
||||
aria-label={"#{@label}: #{@value}"}
|
||||
data-chart-label={@label}
|
||||
data-chart-value={@value}
|
||||
>
|
||||
<div
|
||||
class={[
|
||||
"w-full rounded-t opacity-80 transition-opacity",
|
||||
@color_class,
|
||||
if(@grouped, do: "group-hover:opacity-100")
|
||||
]}
|
||||
style={"height: #{@percentage}%"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-20 w-full shrink-0 overflow-visible">
|
||||
<div
|
||||
class={[
|
||||
"absolute top-6 left-1/2 w-16 origin-center -translate-x-1/2 -rotate-90 text-center text-xs font-medium whitespace-nowrap",
|
||||
"text-zinc-500 dark:text-zinc-400",
|
||||
if(@grouped, do: "group-hover:text-zinc-700 dark:group-hover:text-zinc-200")
|
||||
]}
|
||||
title={@label}
|
||||
>
|
||||
{@label}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp max_value(data, value_fn) do
|
||||
max = Enum.max_by(data, value_fn)
|
||||
value_fn.(max)
|
||||
|
||||
@@ -82,9 +82,9 @@ defmodule MusicLibraryWeb.StatsLive.Index do
|
||||
<.section>
|
||||
<:title>{gettext("Daily Scrobbles")}</:title>
|
||||
<div class="mt-5 rounded-md bg-white shadow-sm dark:bg-zinc-800">
|
||||
<.horizontal_bar_chart
|
||||
<.vertical_bar_chart
|
||||
data={@daily_scrobble_counts}
|
||||
color_class="bg-emerald-500"
|
||||
color_class="bg-red-500 dark:bg-red-400"
|
||||
label_fn={fn %{date: date, count: _count} -> Calendar.strftime(date, "%b %d") end}
|
||||
value_fn={fn %{date: _date, count: count} -> count end}
|
||||
/>
|
||||
|
||||
@@ -508,14 +508,15 @@ defmodule MusicLibraryWeb.StatsLive.IndexTest do
|
||||
end
|
||||
|
||||
defp assert_daily_chart_count(session, label, expected_count) do
|
||||
value_texts =
|
||||
chart_datums =
|
||||
session.view
|
||||
|> render()
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.query(~s(#daily-scrobble-counts [title="#{label}"] + div + div))
|
||||
|> Enum.map(&(LazyHTML.text(&1) |> String.trim()))
|
||||
|> LazyHTML.query(
|
||||
~s(#daily-scrobble-counts [data-chart-label="#{label}"][data-chart-value="#{expected_count}"])
|
||||
)
|
||||
|
||||
assert value_texts == [to_string(expected_count)]
|
||||
assert Enum.count_until(chart_datums, 2) == 1
|
||||
|
||||
session
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user