ML-200: daily scrobble count charts
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
---
|
||||
id: ML-200
|
||||
title: scrobble count per day widget
|
||||
status: To Do
|
||||
assignee: []
|
||||
status: Done
|
||||
assignee:
|
||||
- cloud
|
||||
created_date: "2026-05-31 18:02"
|
||||
updated_date: "2026-05-31 18:23"
|
||||
updated_date: "2026-05-31 18:50"
|
||||
labels: []
|
||||
dependencies: []
|
||||
documentation:
|
||||
@@ -24,14 +25,14 @@ In the stats page, right before Scrobble Activity, add a horizontal bar chart th
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
|
||||
- [ ] #1 Stats page displays a new scrobble-count-per-day chart immediately before the existing Scrobble Activity section.
|
||||
- [ ] #2 The chart shows exactly 30 local calendar days: today plus the previous 29 days, ordered oldest to newest.
|
||||
- [ ] #3 Each day displays the count of scrobbled track rows for that local day, including zero-count days.
|
||||
- [ ] #4 Daily grouping respects the user's configured/browser timezone rather than UTC-only day boundaries.
|
||||
- [ ] #5 The chart refreshes when new scrobbles are imported and the existing listening-stats update notification is received.
|
||||
- [ ] #6 The implementation has context-level tests for daily count generation, zero-fill behavior, ordering, and timezone boundaries.
|
||||
- [ ] #7 The stats page has LiveView tests proving the chart is rendered in the correct location with expected labels/counts.
|
||||
- [ ] #8 All user-facing chart text is gettext-wrapped and gettext catalogs are updated.
|
||||
- [x] #1 Stats page displays a new scrobble-count-per-day chart immediately before the existing Scrobble Activity section.
|
||||
- [x] #2 The chart shows exactly 30 local calendar days: today plus the previous 29 days, ordered oldest to newest.
|
||||
- [x] #3 Each day displays the count of scrobbled track rows for that local day, including zero-count days.
|
||||
- [x] #4 Daily grouping respects the user's configured/browser timezone rather than UTC-only day boundaries.
|
||||
- [x] #5 The chart refreshes when new scrobbles are imported and the existing listening-stats update notification is received.
|
||||
- [x] #6 The implementation has context-level tests for daily count generation, zero-fill behavior, ordering, and timezone boundaries.
|
||||
- [x] #7 The stats page has LiveView tests proving the chart is rendered in the correct location with expected labels/counts.
|
||||
- [x] #8 All user-facing chart text is gettext-wrapped and gettext catalogs are updated.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
@@ -164,7 +165,10 @@ No manual production changes are expected.
|
||||
- With the dev server running, open the stats page and capture screenshots after the chart is visible.
|
||||
- Verify both light and dark mode if custom colors/classes are added or changed.
|
||||
- Verification before completion: chart appears immediately before Scrobble Activity, has 30 bars/rows, remains readable at common viewport widths, excludes future/out-of-window rows, and dark-mode colors are paired/correct.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
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.
|
||||
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
@@ -172,4 +176,54 @@ No manual production changes are expected.
|
||||
|
||||
Updated plan and research doc after the existing chart component was renamed from `vertical_bar_chart/1` to `horizontal_bar_chart/1`, so implementers should use the corrected component name.
|
||||
|
||||
Steps 1-4 complete: context function `daily_scrobble_counts/1`, widget in StatsLive.Index before scrobble-activity, PubSub refresh wired via assign_scrobble_activity/1. Context tests (6) pass. LiveView tests (3) pass.
|
||||
|
||||
Review found the context/query implementation is sound, but the LiveView test suite does not exercise the post-mount `%{track_count: n}` refresh path and has a weak count assertion that can pass on date-label text.
|
||||
|
||||
Review fixes complete: the LiveView test now pairs the expected date label with the adjacent chart value via LazyHTML instead of matching arbitrary descendant text, and a new connected LiveView test verifies `ListeningStats.update/1` broadcasts a nonzero update that refreshes the daily chart after mount.
|
||||
|
||||
Credo follow-up: replaced `length(...) == literal` assertions in `daily_scrobble_counts/1` tests with `Enum.count_until/2`; full `mix credo --strict` now passes.
|
||||
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
|
||||
## 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.
|
||||
|
||||
### New context function (`MusicLibrary.ListeningStats.daily_scrobble_counts/1`)
|
||||
|
||||
- Accepts `timezone`, `days` (default 30), and `current_time` (testable)
|
||||
- Computes local calendar boundaries using `Date.add/2` (DST-safe, not fixed 24-hour intervals)
|
||||
- Queries `scrobbled_tracks` with a bounded range scan on `scrobbled_at_uts` using the existing `scrobbled_tracks_scrobbled_at_uts_title_index` covering index
|
||||
- Groups timestamps by local `Date` in Elixir, fills zeroes for days with no scrobbles
|
||||
- Returns 30 `%{date: Date.t(), count: non_neg_integer()}` entries, oldest to newest
|
||||
|
||||
### 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`)
|
||||
- Refreshes through the existing `assign_scrobble_activity/1` path when PubSub receives `%{track_count: n}` for nonzero imports
|
||||
|
||||
### Tests
|
||||
|
||||
- Context tests cover 30-day length, ordering, zero-fill, boundary exclusion/inclusion, row counting (not distinct timestamps), and timezone grouping
|
||||
- LiveView tests cover section rendering, DOM order before Scrobble Activity, precise date/count pairing, and post-mount PubSub refresh after `ListeningStats.update/1`
|
||||
|
||||
### 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 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
|
||||
|
||||
### Risks / follow-ups
|
||||
|
||||
- No production changes or migrations needed
|
||||
- Query currently loads the bounded 30-day timestamp window into memory; if the app ever has very high recent scrobble volume, revisit a materialized daily-count table.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -163,6 +163,53 @@ defmodule MusicLibrary.ListeningStats do
|
||||
Calendar.strftime(ldt, "%d/%m/%Y %X")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns daily scrobble counts for the last N calendar days in the given timezone.
|
||||
|
||||
Returns a list of maps `%{date: Date.t(), count: non_neg_integer()}` ordered
|
||||
oldest to newest, with zero-filled entries for days with no scrobbles.
|
||||
|
||||
## Options
|
||||
|
||||
* `:timezone` — IANA timezone string (default: configured `default_timezone`)
|
||||
* `:days` — number of days to return, including today (default: 30)
|
||||
* `:current_time` — `DateTime` to treat as "now" (default: `DateTime.utc_now/0`;
|
||||
injectable for testing)
|
||||
"""
|
||||
@spec daily_scrobble_counts(keyword()) :: [%{date: Date.t(), count: non_neg_integer()}]
|
||||
def daily_scrobble_counts(opts \\ []) do
|
||||
timezone = Keyword.get(opts, :timezone, MusicLibrary.default_timezone())
|
||||
days = Keyword.get(opts, :days, 30)
|
||||
current_time = Keyword.get_lazy(opts, :current_time, &DateTime.utc_now/0)
|
||||
|
||||
today = current_time |> DateTime.shift_zone!(timezone) |> DateTime.to_date()
|
||||
first_date = Date.add(today, -(days - 1))
|
||||
tomorrow = Date.add(today, 1)
|
||||
|
||||
start_uts = local_midnight_unix(first_date, timezone)
|
||||
end_uts = local_midnight_unix(tomorrow, timezone)
|
||||
|
||||
query =
|
||||
from t in Track,
|
||||
where: t.scrobbled_at_uts >= ^start_uts and t.scrobbled_at_uts < ^end_uts,
|
||||
order_by: [asc: t.scrobbled_at_uts],
|
||||
select: t.scrobbled_at_uts
|
||||
|
||||
timestamps = Repo.all(query)
|
||||
|
||||
counts_by_date =
|
||||
Enum.reduce(timestamps, %{}, fn uts, acc ->
|
||||
date =
|
||||
uts |> DateTime.from_unix!() |> DateTime.shift_zone!(timezone) |> DateTime.to_date()
|
||||
|
||||
Map.update(acc, date, 1, &(&1 + 1))
|
||||
end)
|
||||
|
||||
Enum.map(Date.range(first_date, today), fn date ->
|
||||
%{date: date, count: Map.get(counts_by_date, date, 0)}
|
||||
end)
|
||||
end
|
||||
|
||||
# Track CRUD + listing
|
||||
|
||||
@spec list_tracks(map()) :: [map()]
|
||||
@@ -524,6 +571,18 @@ defmodule MusicLibrary.ListeningStats do
|
||||
|> DateTime.to_unix()
|
||||
end
|
||||
|
||||
# Converts a local calendar-date midnight to a Unix timestamp in the given
|
||||
# timezone. Handles DST gaps (spring-forward) by using the wall-clock time
|
||||
# just after the gap, and DST ambiguities (fall-back) by using the first
|
||||
# occurrence of midnight.
|
||||
defp local_midnight_unix(date, timezone) do
|
||||
case DateTime.new(date, ~T[00:00:00], timezone) do
|
||||
{:ok, dt} -> DateTime.to_unix(dt)
|
||||
{:ambiguous, first_dt, _second_dt} -> DateTime.to_unix(first_dt)
|
||||
{:gap, _just_before, just_after} -> DateTime.to_unix(just_after)
|
||||
end
|
||||
end
|
||||
|
||||
defp polyfill_track(track, timezone, artist_id) do
|
||||
%{
|
||||
track
|
||||
|
||||
@@ -78,6 +78,20 @@ defmodule MusicLibraryWeb.StatsLive.Index do
|
||||
</.async_result>
|
||||
</div>
|
||||
|
||||
<div id="daily-scrobble-counts">
|
||||
<.section>
|
||||
<:title>{gettext("Daily Scrobbles")}</:title>
|
||||
<div class="mt-5 rounded-md bg-white shadow-sm dark:bg-zinc-800">
|
||||
<.horizontal_bar_chart
|
||||
data={@daily_scrobble_counts}
|
||||
color_class="bg-emerald-500"
|
||||
label_fn={fn %{date: date, count: _count} -> Calendar.strftime(date, "%b %d") end}
|
||||
value_fn={fn %{date: _date, count: count} -> count end}
|
||||
/>
|
||||
</div>
|
||||
</.section>
|
||||
</div>
|
||||
|
||||
<.scrobble_activity
|
||||
scrobble_activity_mode={@scrobble_activity_mode}
|
||||
streams={@streams}
|
||||
@@ -141,6 +155,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do
|
||||
|> stream(:recent_tracks, [])
|
||||
|> stream(:recent_albums, [])
|
||||
|> assign(
|
||||
daily_scrobble_counts: ListeningStats.daily_scrobble_counts(timezone: timezone),
|
||||
current_date: current_date,
|
||||
latest_record: Collection.get_latest_record(),
|
||||
collection_count: Collection.count(),
|
||||
@@ -633,12 +648,15 @@ defmodule MusicLibraryWeb.StatsLive.Index do
|
||||
end
|
||||
|
||||
defp assign_scrobble_activity(socket) do
|
||||
timezone = socket.assigns.timezone
|
||||
|
||||
%{
|
||||
recent_tracks: recent_tracks,
|
||||
recent_albums: recent_albums
|
||||
} = ListeningStats.recent_activity(socket.assigns.timezone)
|
||||
} = ListeningStats.recent_activity(timezone)
|
||||
|
||||
scrobble_count = ListeningStats.scrobble_count()
|
||||
daily_scrobble_counts = ListeningStats.daily_scrobble_counts(timezone: timezone)
|
||||
|
||||
last_updated_uts =
|
||||
if rt = List.first(recent_tracks) do
|
||||
@@ -648,6 +666,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do
|
||||
socket
|
||||
|> assign(:last_updated_uts, last_updated_uts)
|
||||
|> assign(:scrobble_count, scrobble_count)
|
||||
|> assign(:daily_scrobble_counts, daily_scrobble_counts)
|
||||
|> stream(:recent_tracks, recent_tracks, reset: true)
|
||||
|> stream(:recent_albums, recent_albums, reset: true)
|
||||
end
|
||||
|
||||
@@ -2583,3 +2583,8 @@ msgstr ""
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/live/stats_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Daily Scrobbles"
|
||||
msgstr ""
|
||||
|
||||
@@ -2583,3 +2583,8 @@ msgstr ""
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/live/stats_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Daily Scrobbles"
|
||||
msgstr ""
|
||||
|
||||
@@ -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