ML-200: daily scrobble count charts

This commit is contained in:
Claudio Ortolina
2026-05-31 21:52:06 +03:00
parent b3b8f0546e
commit e67b172d64
7 changed files with 457 additions and 14 deletions
+179
View File
@@ -722,6 +722,185 @@ defmodule MusicLibrary.ListeningStatsTest do
end
end
describe "daily_scrobble_counts/1" do
@timezone "Etc/UTC"
defp uts_for_date(date) do
{:ok, noon} = DateTime.new(date, ~T[12:00:00], @timezone)
DateTime.to_unix(noon)
end
test "returns exactly 30 days ordered oldest to newest" do
today = ~D[2026-05-31]
{:ok, now} = DateTime.new(today, ~T[11:00:00], @timezone)
track_fixture(%{title: "Today", scrobbled_at_uts: uts_for_date(today)})
results =
ListeningStats.daily_scrobble_counts(
timezone: @timezone,
days: 30,
current_time: now
)
assert Enum.count_until(results, 31) == 30
first_date = Date.add(today, -29)
expected_dates = Enum.to_list(Date.range(first_date, today))
assert Enum.map(results, & &1.date) == expected_dates
assert Enum.all?(results, &is_integer(&1.count))
end
test "zero-fills days with no scrobbles" do
today = ~D[2026-05-31]
{:ok, now} = DateTime.new(today, ~T[11:00:00], @timezone)
day_1 = Date.add(today, -29)
day_2 = Date.add(today, -15)
track_fixture(%{title: "Day 1", scrobbled_at_uts: uts_for_date(day_1)})
track_fixture(%{title: "Day 15", scrobbled_at_uts: uts_for_date(day_2)})
results =
ListeningStats.daily_scrobble_counts(
timezone: @timezone,
days: 30,
current_time: now
)
non_zero = Enum.reject(results, &(&1.count == 0))
assert Enum.count_until(non_zero, 3) == 2
day_1_entry = Enum.find(results, &(&1.date == day_1))
assert day_1_entry.count == 1
day_2_entry = Enum.find(results, &(&1.date == day_2))
assert day_2_entry.count == 1
# All other 28 days should be zero
assert 28 == Enum.count(results, &(&1.count == 0))
end
test "excludes tracks before the window" do
today = ~D[2026-05-31]
{:ok, now} = DateTime.new(today, ~T[11:00:00], @timezone)
first_date = Date.add(today, -29)
before_window = Date.add(first_date, -1)
track_fixture(%{title: "Before", scrobbled_at_uts: uts_for_date(before_window)})
track_fixture(%{title: "In window", scrobbled_at_uts: uts_for_date(first_date)})
results =
ListeningStats.daily_scrobble_counts(
timezone: @timezone,
days: 30,
current_time: now
)
first_day_entry = Enum.find(results, &(&1.date == first_date))
assert first_day_entry.count == 1
# Total non-zero should be exactly 1 (the before-window track is excluded)
assert Enum.count(results, &(&1.count > 0)) == 1
end
test "excludes tracks at or after tomorrow's midnight" do
today = ~D[2026-05-31]
{:ok, now} = DateTime.new(today, ~T[11:00:00], @timezone)
tomorrow = Date.add(today, 1)
# Track exactly at tomorrow midnight (should be excluded)
{:ok, tomorrow_midnight} = DateTime.new(tomorrow, ~T[00:00:00], @timezone)
track_fixture(%{
title: "Tomorrow",
scrobbled_at_uts: DateTime.to_unix(tomorrow_midnight)
})
track_fixture(%{title: "Today", scrobbled_at_uts: uts_for_date(today)})
results =
ListeningStats.daily_scrobble_counts(
timezone: @timezone,
days: 30,
current_time: now
)
today_entry = Enum.find(results, &(&1.date == today))
assert today_entry.count == 1
# No entry for tomorrow in the results (30 days = today + 29 previous)
refute Enum.any?(results, &(&1.date == tomorrow))
# Only one track counted
assert Enum.count(results, &(&1.count > 0)) == 1
end
test "counts rows, not distinct timestamps" do
today = ~D[2026-05-31]
{:ok, now} = DateTime.new(today, ~T[11:00:00], @timezone)
# Two tracks at the exact same timestamp on today
same_uts = uts_for_date(today)
track_fixture(%{title: "Track A", scrobbled_at_uts: same_uts})
track_fixture(%{title: "Track B", scrobbled_at_uts: same_uts})
results =
ListeningStats.daily_scrobble_counts(
timezone: @timezone,
days: 30,
current_time: now
)
today_entry = Enum.find(results, &(&1.date == today))
assert today_entry.count == 2
end
test "groups by local date, respecting timezone boundaries" do
# Use `Etc/GMT+5` which is 5 hours behind UTC.
# A track at 03:00 UTC on day X is 22:00 local on day X-1.
tz = "Etc/GMT+5"
today = ~D[2026-05-31]
{:ok, now} = DateTime.new(today, ~T[11:00:00], tz)
# In GMT+5, today starts at 05:00 UTC (midnight local = 05:00 UTC)
# A track at 04:00 UTC on 'today' is actually 23:00 local on 'yesterday'
yesterday_local = Date.add(today, -1)
# This track is at 04:00 UTC on 'today' date — that's 23:00 GMT+5 on yesterday
{:ok, utc_4am_today} = DateTime.new(today, ~T[04:00:00], "Etc/UTC")
track_fixture(%{
title: "Late night",
scrobbled_at_uts: DateTime.to_unix(utc_4am_today)
})
# This track is at 06:00 UTC on 'today' date — that's 01:00 GMT+5 on today
{:ok, utc_6am_today} = DateTime.new(today, ~T[06:00:00], "Etc/UTC")
track_fixture(%{
title: "Early morning",
scrobbled_at_uts: DateTime.to_unix(utc_6am_today)
})
results =
ListeningStats.daily_scrobble_counts(
timezone: tz,
days: 30,
current_time: now
)
yesterday_entry = Enum.find(results, &(&1.date == yesterday_local))
assert yesterday_entry.count == 1
today_entry = Enum.find(results, &(&1.date == today))
assert today_entry.count == 1
end
end
describe "get_top_artists_by_days/2" do
test "counts tracks with missing artist_infos records within date range" do
artist_info =
@@ -6,7 +6,7 @@ defmodule MusicLibraryWeb.StatsLive.IndexTest do
import MusicLibrary.Fixtures.Records
import MusicLibraryWeb.RecordComponents, only: [format_label: 1, type_label: 1]
alias MusicLibrary.{Records, Repo, Wishlist}
alias MusicLibrary.{ListeningStats, Records, Repo, Wishlist}
defp fill_collection(_) do
current_time = DateTime.utc_now()
@@ -444,4 +444,126 @@ defmodule MusicLibraryWeb.StatsLive.IndexTest do
assert_path(session, ~p"/wishlist/#{wishlisted_record.id}")
end
end
describe "Daily Scrobble Counts" do
# Need render/1 for raw HTML assertions (not auto-imported by ConnCase)
import Phoenix.LiveViewTest, only: [render: 1]
defp daily_chart_timezone, do: MusicLibrary.default_timezone()
defp chart_test_date do
DateTime.utc_now()
|> DateTime.shift_zone!(daily_chart_timezone())
|> DateTime.to_date()
|> Date.add(-1)
end
defp chart_label(date) do
Calendar.strftime(date, "%b %d")
end
defp create_chart_track(attrs) do
date = Keyword.fetch!(attrs, :date)
offset_seconds = Keyword.get(attrs, :offset_seconds, 0)
title = Keyword.get_lazy(attrs, :title, &unique_track_title/0)
timezone = daily_chart_timezone()
{:ok, date_noon} = DateTime.new(date, ~T[12:00:00], timezone)
scrobbled_at = DateTime.add(date_noon, offset_seconds, :second)
%LastFm.Track{
musicbrainz_id: "test-daily",
title: title,
artist: %LastFm.Artist{musicbrainz_id: "", name: "Daily Artist"},
album: %LastFm.Album{musicbrainz_id: "", title: "Daily Album"},
cover_url: "https://example.com/daily.jpg",
scrobbled_at_uts: DateTime.to_unix(scrobbled_at),
scrobbled_at_label: "test",
last_fm_data: %{}
}
end
defp insert_chart_tracks(date, count) do
tracks =
Enum.map(1..count, fn index ->
create_chart_track(
date: date,
offset_seconds: index,
title: unique_track_title(index)
)
end)
assert {:ok, ^count} = ListeningStats.update(tracks)
tracks
end
defp unique_track_title(suffix \\ nil) do
unique = System.unique_integer([:positive])
case suffix do
nil -> "Test Daily Track #{unique}"
suffix -> "Test Daily Track #{suffix}-#{unique}"
end
end
defp assert_daily_chart_count(session, label, expected_count) do
value_texts =
session.view
|> render()
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s(#daily-scrobble-counts [title="#{label}"] + div + div))
|> Enum.map(&(LazyHTML.text(&1) |> String.trim()))
assert value_texts == [to_string(expected_count)]
session
end
test "renders the daily scrobble counts section before scrobble activity", %{conn: conn} do
chart_date = chart_test_date()
insert_chart_tracks(chart_date, 1)
session = conn |> visit("/")
assert_has(session, "#daily-scrobble-counts h1", "Daily Scrobbles")
end
test "renders with correct date labels and counts", %{conn: conn} do
chart_date = chart_test_date()
insert_chart_tracks(chart_date, 2)
session = conn |> visit("/")
assert_daily_chart_count(session, chart_label(chart_date), 2)
end
test "refreshes daily counts when listening stats update notification is received", %{
conn: conn
} do
chart_date = chart_test_date()
insert_chart_tracks(chart_date, 1)
session = conn |> visit("/")
label = chart_label(chart_date)
assert_daily_chart_count(session, label, 1)
new_track = create_chart_track(date: chart_date, offset_seconds: 60)
assert {:ok, 1} = ListeningStats.update([new_track])
assert_daily_chart_count(session, label, 2)
end
test "appears before scrobble activity in DOM order", %{conn: conn} do
chart_date = chart_test_date()
insert_chart_tracks(chart_date, 1)
session = conn |> visit("/")
html = render(session.view)
# daily-scrobble-counts must appear before scrobble-activity in the HTML
assert html =~ ~r/daily-scrobble-counts.*scrobble-activity/s
end
end
end