ML-200: daily scrobble count charts
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user