ML-200: switch to a vertical chart

This commit is contained in:
Claudio Ortolina
2026-05-31 22:02:12 +03:00
parent e67b172d64
commit e64f2b12c4
4 changed files with 142 additions and 13 deletions
@@ -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