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
+59
View File
@@ -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
+20 -1
View File
@@ -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