Compare commits

...

10 Commits

Author SHA1 Message Date
Claudio Ortolina e36d80818e Update project conventions for partitioned tests and new credo check
Mix Dependency Submission / Report Mix Dependencies (push) Failing after 1m8s
Test & Deploy / test (push) Has been cancelled
Test & Deploy / Deploy (push) Has been cancelled
Test & Deploy / lint (push) Has been cancelled
2026-05-31 22:38:25 +03:00
Claudio Ortolina 0bdffbc12a Update erlang to 28.5.0.1 2026-05-31 22:33:16 +03:00
Claudio Ortolina e64f2b12c4 ML-200: switch to a vertical chart 2026-05-31 22:02:12 +03:00
Claudio Ortolina e67b172d64 ML-200: daily scrobble count charts 2026-05-31 21:52:06 +03:00
Claudio Ortolina b3b8f0546e ML-200: daily scrobble count charts (plan) 2026-05-31 21:23:55 +03:00
Claudio Ortolina 611ca7e386 Rename vertical_bar_chart -> horizontal_bar_chart 2026-05-31 21:13:53 +03:00
Claudio Ortolina 88d75afa3e Work around Discogs TLS chain issue 2026-05-31 16:52:53 +03:00
Claudio Ortolina 3617f0e7b3 Update backlog.md 2026-05-31 07:30:18 +03:00
Claudio Ortolina 3e7a71f522 Update dependencies
mix:
  yaml_elixir 2.12.1 => 2.12.2
2026-05-30 23:28:11 +03:00
Claudio Ortolina 7a2c3e2d0d Update dependencies
mix:
  swoosh 1.25.3 => 1.26.0
2026-05-30 11:16:07 +03:00
17 changed files with 956 additions and 27 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
# - Ex: hexpm/elixir:1.17.2-erlang-27.1-debian-bullseye-20240904-slim
#
ARG ELIXIR_VERSION=1.20.0-rc.6
ARG OTP_VERSION=28.5
ARG OTP_VERSION=28.5.0.1
ARG DEBIAN_VERSION=trixie-20260518-slim
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
@@ -0,0 +1,114 @@
---
id: doc-30
title: "ML-200 Research: Scrobble daily count widget routes"
type: specification
created_date: "2026-05-31 18:05"
updated_date: "2026-05-31 18:15"
tags:
- research
- stats
- scrobbles
- liveview
---
# ML-200 Research: Scrobble daily count widget routes
## Problem
The stats dashboard should show a horizontal bar chart immediately before the existing Scrobble Activity section. The chart should cover the last 30 calendar days and display the count of scrobbled tracks per day.
## Current architecture touchpoints
- `MusicLibraryWeb.StatsLive.Index` renders the `/` stats page. It already imports `MusicLibraryWeb.ChartComponents`, subscribes to `ListeningStats` PubSub updates when connected, and refreshes scrobble-related assigns when new scrobbles arrive.
- `MusicLibrary.ListeningStats` owns listening analytics queries. LiveViews should call this context rather than querying `LastFm.Track` directly.
- `LastFm.Track` persists scrobbles in `scrobbled_tracks`; `scrobbled_at_uts` is an integer Unix timestamp in seconds.
- The existing unique index `scrobbled_tracks_scrobbled_at_uts_title_index` supports range scans on `scrobbled_at_uts`. `EXPLAIN QUERY PLAN SELECT scrobbled_at_uts FROM scrobbled_tracks WHERE scrobbled_at_uts >= ? ORDER BY scrobbled_at_uts ASC` reports a covering index search using that index.
- `MusicLibraryWeb.ChartComponents.horizontal_bar_chart/1` renders horizontal bars with CSS Grid. Project UI conventions prefer CSS Grid charts over SVG.
- The stats page receives the user/browser timezone via `socket.assigns.timezone`; existing top-period stats use this timezone to compute day cutoffs.
Local development data observed during research: `scrobbled_tracks` contains 105,936 total rows and 1,002 rows in the most recent 30 days. This is useful as a realistic order-of-magnitude check for a personal library dashboard read.
## Route A — Context range scan, group by local day in Elixir, render with existing chart component
Add a `ListeningStats` function that accepts `timezone`, `days`, and an optional `current_time` for tests. It computes the local 30-day window, queries only `scrobbled_at_uts` values in that range, converts each timestamp to the user's timezone, groups by `Date`, fills missing days with zero counts, and returns a fixed 30-item list sorted by date. `StatsLive.Index` assigns the result, renders it before Scrobble Activity using `ChartComponents.horizontal_bar_chart/1` or a small stats-specific wrapper, and refreshes it from the existing `listening_stats:update` PubSub path.
Pros:
- Correct for the user's IANA timezone and DST boundaries because grouping happens through Elixir time zone conversion.
- No migration, trigger, background job, cache, or external API required.
- Keeps query ownership in `ListeningStats` and rendering in the stats web layer.
- Reuses the existing CSS Grid chart approach.
- Query is a narrow covering index range scan and returns only one integer column for the last 30 days.
Cons:
- Fetches one row per scrobble in the 30-day window before grouping. This is acceptable for the current app scale but is not constant-time.
- Needs clear tests around timezone boundaries and zero-count days.
Performance profile:
- Runtime: O(n + d), where `n` is scrobbles in the 30-day window and `d = 30`.
- Database: one indexed range scan over `scrobbled_at_uts`, no joins, no N+1 risk.
- Memory: one integer per recent scrobble plus a 30-entry result map/list.
- Expected local scale: approximately 1,000 rows for 30 days based on current dev data.
## Route B — SQL aggregate by UTC calendar day, fill gaps in Elixir
Use SQLite aggregation directly, for example grouping by `date(scrobbled_at_uts, 'unixepoch')`, and fill missing days in Elixir before rendering.
Pros:
- Returns at most 30 aggregate rows to Elixir.
- Simple query shape and no schema change.
- Still uses the `scrobbled_at_uts` range index for the date filter.
Cons:
- Groups by UTC days, not the user's browser timezone. This can move late-night/early-morning scrobbles to the wrong displayed day for the stats page.
- SQLite does not understand IANA timezones or DST transitions, so accurate browser-timezone grouping cannot be expressed directly in SQLite without additional application logic.
- `EXPLAIN QUERY PLAN` for the UTC aggregate still shows a temporary B-tree for `GROUP BY`.
Use this only if the product decision is that UTC days are acceptable for this widget. That would be inconsistent with the current stats page timezone handling.
## Route C — Materialized daily scrobble count table
Create a rollup table such as `daily_scrobble_counts` and maintain it via migration/backfill plus insert/update/delete handling when `scrobbled_tracks` changes. The stats page reads 30 rows from the rollup.
Pros:
- Dashboard read is constant and tiny.
- Could support future long-range daily charts cheaply.
Cons:
- Adds migration, backfill, maintenance logic, and correctness concerns around deletes/updates to scrobble timestamps.
- Still must choose a timezone for the stored day bucket. A UTC rollup has the same product issue as Route B; a local-time rollup would only be correct for one configured timezone and would not match arbitrary browser timezones.
- Over-engineered for a 30-day personal dashboard widget at current data volume.
This route should be deferred unless dashboard reads become measurably slow or daily rollups become a broader analytics requirement.
## Route D — Reuse existing recent activity data or fetch Last.fm again
Either derive daily counts from the current `recent_activity/2` result or call Last.fm for a fresh 30-day activity window.
Pros:
- Reusing existing data sounds superficially simple.
Cons:
- `recent_activity/2` defaults to a 100-track limit, so it can miss older scrobbles within the last 30 days when listening volume exceeds 100 tracks.
- Calling Last.fm duplicates data already persisted locally, adds network latency, consumes API quota/rate-limit budget, and introduces external failure modes to the dashboard.
- This route is less reliable and less consistent than querying local `scrobbled_tracks`.
This route should be rejected.
## Recommended route
Route A is the simplest viable implementation that preserves correctness for the stats page timezone model. It avoids schema churn and external dependencies while using an existing indexed range scan and existing chart infrastructure. Route B is faster in bytes returned but trades away local-day correctness. Route C is operationally heavier than the current objective requires. Route D is incomplete or unnecessarily dependent on Last.fm.
## Open product choices before planning
- Confirm whether the 30-day window should include today plus the previous 29 local calendar days. This is the recommended interpretation.
- Confirm chart ordering: oldest-to-newest reads naturally as a timeline, while newest-first keeps today's activity at the top. The recommended default is oldest-to-newest for a timeline chart.
- Confirm whether the count should count scrobbled track rows. This matches the wording “scrobbled tracks per day” and the existing total scrobble counter semantics.
@@ -0,0 +1,243 @@
---
id: ML-200
title: scrobble count per day widget
status: Done
assignee:
- cloud
created_date: "2026-05-31 18:02"
updated_date: "2026-05-31 18:59"
labels: []
dependencies: []
documentation:
- doc-30 - ML-200-Research-Scrobble-daily-count-widget-routes.md
ordinal: 33000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
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 -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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
<!-- SECTION:PLAN:BEGIN -->
# Implementation Plan — Route A (revised after plan review)
## Objective alignment
Add a stats dashboard widget immediately before the existing Scrobble Activity section that shows scrobbled track row counts for the last 30 local calendar days. The solution maps directly to the issue by adding a `MusicLibrary.ListeningStats` daily-count query for today plus the previous 29 days, rendering the fixed 30-day result as a horizontal bar chart on `MusicLibraryWeb.StatsLive.Index`, and refreshing the same data through the existing listening-stats PubSub update path.
## Chosen approach and alternatives considered
**Chosen approach: Route A — bounded context range scan + Elixir local-day grouping.** `MusicLibrary.ListeningStats` will own a new public function that accepts `timezone`, `days`, and testable `current_time` options. It will compute the exact local calendar window, query only `scrobbled_at_uts` values from `scrobbled_tracks` within that window, convert timestamps to the requested IANA timezone in Elixir, group by `Date`, fill missing days with zero counts, and return 30 oldest-to-newest entries.
The query must use both bounds for the local window:
- `scrobbled_at_uts >= start_of_first_local_day`
- `scrobbled_at_uts < start_of_tomorrow_local_day`
This keeps the query bounded to exactly today plus the previous 29 local calendar days and prevents future/out-of-window rows from appearing.
This remains the simplest viable approach because it needs no migration, no cache, no background worker, no external API call, and no new infrastructure while preserving correct local-day semantics.
Alternatives evaluated:
- **SQL UTC aggregation:** deferred because SQLite can cheaply group by UTC day, but the widget must respect the stats page timezone model. UTC grouping would misclassify scrobbles around local midnight and DST boundaries.
- **Materialized daily count table:** rejected for now because it adds schema, backfill, write-path maintenance, and timezone-bucket ambiguity for a small 30-day dashboard read.
- **Reuse recent activity or call Last.fm:** rejected because recent activity is capped and can miss tracks in busy 30-day windows; Last.fm calls duplicate local data, add latency, and consume external API/rate-limit budget.
Research details are captured in `doc-30 - ML-200-Research-Scrobble-daily-count-widget-routes.md`.
## Architecture impact analysis
- **Schemas:** no schema changes. `LastFm.Track` remains unchanged; counts are derived from existing `scrobbled_tracks.scrobbled_at_uts` rows.
- **Contexts:** add a public analytics function to `MusicLibrary.ListeningStats`. The LiveView must not query the database directly.
- **Database/indexes:** use the existing unique index on `[:scrobbled_at_uts, :title]` for an indexed bounded range scan. No migration is expected unless `EXPLAIN QUERY PLAN` shows the final bounded range query is not using that index.
- **LiveView/UI:** update `MusicLibraryWeb.StatsLive.Index` to assign/render the new chart immediately before `.scrobble_activity`. Add a stable wrapper id such as `id="daily-scrobble-counts"` so tests can verify ordering relative to the existing `id="scrobble-activity"` section.
- **Components:** prefer reusing `MusicLibraryWeb.ChartComponents.horizontal_bar_chart/1`; add only a small stats-specific wrapper if needed for labels, empty state, or accessible text.
- **PubSub:** reuse existing `ListeningStats.subscribe/0` topic and `%{track_count: n}` messages. Update the existing refresh helper so daily counts refresh with the total count and recent activity on nonzero updates.
- **Routes:** no route changes.
- **External APIs:** no Last.fm/API calls for this widget; data comes from local persistence.
- **Supervision tree/workers:** no supervision or Oban worker changes.
- **Gettext:** new visible strings must be wrapped and catalogs updated.
## Performance profile
- **Runtime complexity:** O(n + d), where `n` is scrobbles in the exact 30-day local window and `d = 30` fixed output days.
- **Database query pattern:** one indexed bounded range scan selecting only `scrobbled_at_uts`, ordered ascending. No joins and no per-day query loop, so no N+1 risk.
- **Expected SQL shape:** `SELECT scrobbled_at_uts FROM scrobbled_tracks WHERE scrobbled_at_uts >= ? AND scrobbled_at_uts < ? ORDER BY scrobbled_at_uts ASC`.
- **Memory footprint:** one integer per scrobble in the recent window plus a 30-entry result list/map. Current local data observed during research had about 1,002 scrobbles in the last 30 days.
- **Latency/throughput:** expected dashboard impact is small for a personal collection app. The query runs on page mount and when new scrobbles are imported via existing PubSub notifications.
- **Failure mode:** if the query fails unexpectedly, it should fail like existing synchronous stats assigns unless implemented as async; do not introduce external-service failure paths.
## Benchmarking requirements
No ongoing benchmark is required before implementation because the query is a bounded 30-day indexed range scan and current observed row volume is modest.
One-off validation is required during implementation:
1. Run `EXPLAIN QUERY PLAN` for the final bounded SQL shape and confirm SQLite uses `scrobbled_tracks_scrobbled_at_uts_title_index` or an equivalent covering/range index for `scrobbled_at_uts >= ? AND scrobbled_at_uts < ?`.
2. Use development data or seeded test data to confirm the number of fetched rows is limited to the exact local 30-day window.
3. If local or production-like data shows the query is slow enough to affect dashboard rendering, stop and revisit Route C rather than adding an ad hoc cache.
Acceptable threshold for this task: one dashboard read should perform a single indexed bounded range query for daily counts and should not introduce any query loop proportional to 30 days.
## Cost profile
No paid resources are consumed. The implementation uses local SQLite data and existing LiveView rendering. It does not call Last.fm or any other external API, does not increase storage, and does not require additional compute services.
## Production Changes
No manual production changes are expected.
- **Environment variables:** none.
- **Service provisioning:** none.
- **Database migrations:** none expected.
- **DNS/firewall:** none.
- **Rollout:** deploy the code normally through the existing release pipeline.
- **Rollback:** revert the application code if the widget causes issues; no data rollback is required because no schema or persistent data changes are planned.
## Documentation updates
- `docs/architecture.md`: update only if the implementation adds a new named component/module or materially changes the stats dashboard/context map. A small function in `ListeningStats` and markup in `StatsLive.Index` may not require an architecture update unless a new component module is introduced.
- `.agents/skills/*`: no updates expected.
- README/API docs: no updates expected; this is an internal dashboard UI feature.
- Gettext catalogs: update `priv/gettext/default.pot` and locale files after adding user-facing strings.
## Sequential implementation steps with verification
1. **Add the context function in `MusicLibrary.ListeningStats`.**
- Implement a public function such as `daily_scrobble_counts/1` or `scrobble_counts_by_day/1` with options for `timezone`, `days: 30`, and `current_time`.
- Prefer returning a fixed list of maps such as `%{date: Date.t(), count: non_neg_integer()}` ordered oldest to newest.
- Compute calendar boundaries by first shifting `current_time` into the supplied timezone, deriving `today` from that local time, then using `Date.add/2` for calendar-day math. Do not subtract fixed 24-hour second intervals to derive local dates because DST boundaries matter.
- Compute `first_date = Date.add(today, -(days - 1))` and `tomorrow = Date.add(today, 1)`.
- Convert local midnights for `first_date` and `tomorrow` to Unix timestamps in the supplied timezone; use those as the lower and upper SQL bounds.
- Query only `scrobbled_at_uts` values where `scrobbled_at_uts >= start_uts and scrobbled_at_uts < end_uts`, ordered ascending.
- Convert each returned timestamp to the supplied timezone, group by local `Date`, count rows, fill all 30 dates with zeroes, and return oldest-to-newest entries.
- Verification before moving on: add/run focused context tests for exact 30-day length, oldest-to-newest order, zero-filled missing days, counting rows rather than distinct timestamps, exclusion before the window, exclusion at/after tomorrow, inclusion on today, and timezone boundary behavior.
2. **Validate the SQL query shape.**
- Inspect the generated SQL or equivalent query and run `EXPLAIN QUERY PLAN` against SQLite.
- Verification before moving on: confirm the plan uses the existing `scrobbled_at_uts` range/covering index and does not perform a full table scan or 30 separate queries.
3. **Render the widget on `StatsLive.Index`.**
- Assign the daily-count data during mount using the user timezone from the socket.
- Add a section immediately before the existing `.scrobble_activity` call.
- Wrap the section with a stable id such as `daily-scrobble-counts` so tests can assert it appears before `scrobble-activity`.
- Reuse `ChartComponents.horizontal_bar_chart/1` where practical, with labels suitable for local dates and counts as values. If a small wrapper is needed, keep it in the stats/chart component layer and follow project UI conventions: gettext strings, paired dark-mode classes, CSS Grid charting, and accessible text/labels.
- Verification before moving on: run the focused stats LiveView test and inspect rendered HTML ordering to confirm the daily chart section precedes Scrobble Activity.
4. **Refresh the widget when scrobbles update.**
- Extend the existing `assign_scrobble_activity/1` refresh path or equivalent so `%{track_count: n}` PubSub updates refresh total scrobbles, recent activity, and daily counts together.
- Preserve the existing `%{track_count: 0}` no-op behavior unless there is a product reason to reload on zero inserted tracks.
- Verification before moving on: add/run a connected LiveView test that inserts or broadcasts a nonzero listening-stats update and proves the chart data/counts change after the update is received.
5. **Update gettext catalogs.**
- Wrap new headings, empty-state text, accessible labels, and any date/count labels requiring text in `gettext/1` or `ngettext/3` as appropriate.
- Run gettext extraction/merge.
- Verification before moving on: run the gettext up-to-date check or the project task/check that covers it.
6. **Run focused and relevant verification.**
- Run `mix test test/music_library/listening_stats_test.exs`.
- Run `mix test test/music_library_web/live/stats_live/index_test.exs`.
- Run formatting and relevant project verification tasks identified in `docs/available-tasks.md`, such as `mise run dev:lint` or the conditional precommit task, before finalizing implementation.
- Verification before moving on: all focused tests pass, formatting is clean, and no gettext drift remains.
7. **Visual verification.**
- 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.
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
<!-- SECTION:NOTES:BEGIN -->
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.
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
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
## What changed
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`)
- 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`
- 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
- 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 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` / `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
- 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 -->
+1 -1
View File
@@ -126,6 +126,6 @@ Run HTTP tests against production
## `test`
- Depends: dev:partitioned-test 1, dev:partitioned-test 2
- Depends: dev:partitioned-test 1, dev:partitioned-test 2, dev:partitioned-test 3, dev:partitioned-test 4
- **Usage**: `test`
+1 -1
View File
@@ -28,7 +28,7 @@ push via GitHub Actions.
The Docker image is a multi-stage build:
1. **Builder**`hexpm/elixir:1.20.0-rc.6-erlang-28.5-debian-trixie-20260518-slim` with
1. **Builder**`hexpm/elixir:1.20.0-rc.6-erlang-28.5.0.1-debian-trixie-20260518-slim` with
Node.js 26, compiles deps, builds assets (`mix assets.deploy`), generates an OTP release.
2. **Runner**`debian:trixie-20260518-slim` with minimal runtime deps (`libstdc++6`,
`openssl`, `libncurses6`, `ca-certificates`). Runs as unprivileged `nobody` user.
+10 -10
View File
@@ -20,15 +20,15 @@ Key rules: imperative present tense, single-line under 60 characters, task ID pr
The pre-commit hook (`scripts/dev/precommit`) inspects staged files and runs only the checks relevant to the change, rather than always running the full verification suite. CI always runs the full suite unconditionally.
| Category | File pattern | Checks (conditional) |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **elixir** | `lib/`, `test/`, `config/`, `mix.exs`, `mix.lock`, `priv/repo/migrations/`, `priv/gettext/`, `.credo.exs`, `.formatter.exs`, `.sobelow-conf` | `mix format --check-formatted`<br>`mix credo --strict`<br>`mix sobelow --compact --exit`<br>`mix gettext.extract --check-up-to-date`<br>`mix test`<br>`mix deps.unlock --unused` † |
| **shell** | `scripts/`, `.shellcheckrc` | `shellcheck` on all `scripts/` files (excluding `.hurl`) |
| **assets** | `assets/`, `.pi/extensions/.*\.(ts\|js\|json)$` | `prettier` on CSS, JS, and TS/JSON in `.pi/extensions/` (excluding `node_modules`) |
| **docs** | `docs/`, `README.md`, `AGENTS.md` | `prettier` on Markdown and Livebook files |
| **backlog** | `backlog/` | `prettier` on all backlog markdown files |
| **presto** | `presto/` | `(cd presto && mise run test)` — runs pytest |
| **docker** | `Dockerfile`, `.dockerignore`, `compose.yaml` | `mise run dev:validate-docker-image` (only if `Dockerfile` is staged) |
| Category | File pattern | Checks (conditional) |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **elixir** | `lib/`, `test/`, `config/`, `mix.exs`, `mix.lock`, `priv/repo/migrations/`, `priv/gettext/`, `.credo.exs`, `.formatter.exs`, `.sobelow-conf` | `mix format --check-formatted`<br>`mix credo --strict`<br>`mix sobelow --compact --exit`<br>`mix gettext.extract --check-up-to-date`<br>`mise run //:test`<br>`mix deps.unlock --unused` † |
| **shell** | `scripts/`, `.shellcheckrc` | `shellcheck` on all `scripts/` files (excluding `.hurl`) |
| **assets** | `assets/`, `.pi/extensions/.*\.(ts\|js\|json)$` | `prettier` on CSS, JS, and TS/JSON in `.pi/extensions/` (excluding `node_modules`) |
| **docs** | `docs/`, `README.md`, `AGENTS.md` | `prettier` on Markdown and Livebook files |
| **backlog** | `backlog/` | `prettier` on all backlog markdown files |
| **presto** | `presto/` | `(cd presto && mise run test)` — runs pytest |
| **docker** | `Dockerfile`, `.dockerignore`, `compose.yaml` | `mise run dev:validate-docker-image` (only if `Dockerfile` is staged) |
> **† `mix deps.unlock --unused` sub-gate**: Runs only when `mix.exs` or `mix.lock` is in the staged files — not on every Elixir change. An unused dependency can only be introduced by changing the dependency specification, not by changing application code.
@@ -102,7 +102,7 @@ Key rules: feature setup stays in test modules that need it, fixture modules use
- **Markdown sanitization via MDEx (ammonia).** Use `Markdown.to_html/1` for user content. Annotate raw output with `# sobelow_skip ["XSS.Raw"]` and a comment explaining the sanitization.
- **Sobelow runs on CI and pre-commit** in skip mode for security analysis.
- **`mix deps.audit` runs on CI and pre-commit** (warn-only) via `mix_audit` for CVE scanning.
- **ExSlop checks run via Credo** for code quality: no narrator/boilerplate docs (use `@moduledoc false` instead), no obvious/step/narrator comments, no identity `case`/`with` patterns, no `Repo.all` then filter, no `Enum.map` with inline queries, no unaliased nested module use (nested modules must be aliased at the top). Violations are caught by CI.
- **ExSlop and custom Credo checks run via Credo** for code quality: no narrator/boilerplate docs (use `@moduledoc false` instead), no obvious/step/narrator comments, no identity `case`/`with` patterns, no `Repo.all` then filter, no `Enum.map` with inline queries, no unaliased nested module use (nested modules must be aliased at the top), no LiveComponent toast helper misuse (`put_toast!/2` in LiveComponents, `put_toast/3` in LiveViews). Violations are caught by CI.
- **All modules require `@moduledoc`.** The Credo `ModuleDoc` check is enforced in strict mode.
- **Dialyzer is enabled via `dialyxir` (dev/test only) with the `:no_opaque` flag.** Specs are required and checked. Opaque type checking is disabled to tolerate third-party opaque types (e.g., `Ecto.Changeset`).
- **Validate Docker builder image before updating versions.** When changing `ELIXIR_VERSION`, `OTP_VERSION`, or `DEBIAN_VERSION` in the Dockerfile, run `mise run dev:validate-docker-image` to confirm the generated `hexpm/elixir` tag exists on Docker Hub and supports both `linux/amd64` and `linux/arm64`.
+67 -2
View File
@@ -8,6 +8,16 @@ defmodule Discogs.API do
require Logger
# Discogs/Cloudflare currently sends an expired ISRG Root X2 cross-sign after
# the valid leaf/intermediate chain. Browser and curl stacks recover by
# building an alternate path to the locally trusted ISRG Root X2, but OTP's
# verifier rejects the presented chain. Keep TLS verification enabled and
# allow only that known expired cross-sign by fingerprint.
@expired_isrg_root_x2_cross_sign_sha256 Base.decode16!(
"8B05B68CC659E5ED0FCB38F2C942FBFD200E6F2FF9F85D63C6994EF5E0B02701",
case: :upper
)
@spec get_artist(integer() | String.t(), Discogs.Config.t()) ::
{:ok, map()} | {:error, ErrorResponse.t() | Exception.t()}
def get_artist(id, config) do
@@ -24,7 +34,7 @@ defmodule Discogs.API do
{:ok, binary()} | {:error, :cover_not_available}
def get_artist_image(url, config) do
case Req.new(url: url, max_retries: 1, user_agent: config.user_agent)
|> Request.merge_options(config.req_options)
|> Request.merge_options(req_options(config))
|> Request.append_request_steps(log_attempt: &log_attempt/1)
|> Request.append_response_steps(log_error: &log_error/1)
|> get_request() do
@@ -40,12 +50,67 @@ defmodule Discogs.API do
user_agent: config.user_agent,
auth: "Discogs token=#{config.personal_access_token}"
)
|> Request.merge_options(config.req_options)
|> Request.merge_options(req_options(config))
|> Req.RateLimiter.attach(name: :discogs, cooldown: config.api_cooldown)
|> Request.append_request_steps(log_attempt: &log_attempt/1)
|> Request.append_response_steps(parse_error: &parse_error/1)
end
defp req_options(config) do
Keyword.update(
config.req_options,
:connect_options,
discogs_connect_options(),
fn connect_options ->
Keyword.update(
connect_options,
:transport_opts,
discogs_transport_options(),
fn transport_opts ->
Keyword.put_new(transport_opts, :verify_fun, {&verify_discogs_certificate/3, []})
end
)
end
)
end
defp discogs_connect_options do
[transport_opts: discogs_transport_options()]
end
defp discogs_transport_options do
[verify_fun: {&verify_discogs_certificate/3, []}]
end
defp verify_discogs_certificate(cert, {:bad_cert, :cert_expired} = reason, state) do
if expired_isrg_root_x2_cross_sign?(cert) do
{:valid, state}
else
{:fail, reason}
end
end
defp verify_discogs_certificate(_cert, {:bad_cert, _reason} = reason, _state) do
{:fail, reason}
end
defp verify_discogs_certificate(_cert, {:extension, _extension}, state) do
{:unknown, state}
end
defp verify_discogs_certificate(_cert, :valid, state) do
{:valid, state}
end
defp verify_discogs_certificate(_cert, :valid_peer, state) do
{:valid, state}
end
defp expired_isrg_root_x2_cross_sign?(cert) do
der = :public_key.pkix_encode(:OTPCertificate, cert, :otp)
:crypto.hash(:sha256, der) == @expired_isrg_root_x2_cross_sign_sha256
end
defp get_request(request) do
case Req.get(request) do
{:ok, %{status: status, body: body}} when status in 200..299 ->
+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
@@ -14,7 +14,7 @@ defmodule MusicLibraryWeb.ChartComponents do
## Examples
<.vertical_bar_chart
<.horizontal_bar_chart
data={[{"Artist 1", 5}, {"Artist 2", 3}]}
label_fn={&elem(&1, 0)}
value_fn={&elem(&1, 1)}
@@ -28,7 +28,7 @@ defmodule MusicLibraryWeb.ChartComponents do
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
def horizontal_bar_chart(assigns) when assigns.data != [] do
assigns =
assigns
|> assign(:max_value, max_value(assigns.data, assigns.value_fn))
@@ -67,6 +67,77 @@ defmodule MusicLibraryWeb.ChartComponents do
"""
end
def horizontal_bar_chart(assigns) do
~H"""
<div class={["w-full", @class]}>
{gettext("No data available")}
</div>
"""
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]}>
@@ -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)
+23 -4
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">
<.vertical_bar_chart
data={@daily_scrobble_counts}
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}
/>
</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(),
@@ -307,7 +322,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do
<.section>
<:title>{gettext("Top 20 Release Years")}</:title>
<div class="mt-5 rounded-md bg-white shadow-sm dark:bg-zinc-800">
<.vertical_bar_chart
<.horizontal_bar_chart
data={@records_by_release_year}
color_class="bg-zinc-800 dark:bg-zinc-300"
label_fn={fn {year, _count} -> year end}
@@ -331,7 +346,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do
<.section>
<:title>{gettext("Top %{n} Collection Genres", %{n: length(@records_by_genre)})}</:title>
<div class="mt-5 rounded-md bg-white shadow-sm dark:bg-zinc-800">
<.vertical_bar_chart
<.horizontal_bar_chart
data={@records_by_genre}
color_class="bg-zinc-500"
label_fn={fn {genre, _count} -> genre end}
@@ -355,7 +370,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do
<.section>
<:title>{gettext("Top %{n} Collection Artists", %{n: length(@records_by_artist)})}</:title>
<div class="mt-5 rounded-md bg-white shadow-sm dark:bg-zinc-800">
<.vertical_bar_chart
<.horizontal_bar_chart
data={@records_by_artist}
color_class="bg-red-500"
label_fn={fn datum -> datum.name end}
@@ -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
+5 -2
View File
@@ -8,9 +8,12 @@ backend = "core:elixir"
checksum = "blake3:c929482ef634fa41d7a8fe3a3772e046c05389236eeadade6acfa2f73e9c57bf"
[[tools.erlang]]
version = "28.5"
version = "28.5.0.1"
backend = "core:erlang"
[tools.erlang."platforms.macos-arm64"]
checksum = "blake3:bb521a30c24d9278d9f0282336747f6cf9dc4ac52cd5a6cba15fb1358325ae6d"
[[tools.fd]]
version = "10.4.2"
backend = "aqua:sharkdp/fd"
@@ -167,7 +170,7 @@ checksum = "sha256:d0418640a36096e00bddb57761aa0b1b98f91904ec4ed2b9dd75cbad723be
url = "https://nodejs.org/dist/v26.0.0/node-v26.0.0-win-x64.zip"
[[tools."npm:backlog.md"]]
version = "1.45.1"
version = "1.45.2"
backend = "npm:backlog.md"
[[tools."npm:prettier"]]
+1 -1
View File
@@ -29,7 +29,7 @@ HURL_VARIABLE_coolify_token = 'change-me'
[tools]
elixir = "1.20.0-rc.6-otp-28"
erlang = "28.5"
erlang = "28.5.0.1"
node = "26.0.0"
hurl = '8.0.0'
sqlite = 'latest'
+2 -2
View File
@@ -74,7 +74,7 @@
"sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
"spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"},
"statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"},
"swoosh": {:hex, :swoosh, "1.25.3", "c181e353138807e49cc761619466a4b2219714018d8af2e409f67786dfb132fa", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc5d2cd7f24a3ee4249e02a60a0a5c2519d396a8b8798e1ec44ea78714d68d85"},
"swoosh": {:hex, :swoosh, "1.26.0", "8fb146d261e3c4df2d828df0e28a5f233674976c9464c5177f13b7a431a266a9", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "83fe2c7c6572d21d5cdb47766ea041c13da234d09c96220c480eb90b21b1335c"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
@@ -89,5 +89,5 @@
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"},
}
+5
View File
@@ -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 ""
+5
View File
@@ -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 ""
+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,127 @@ 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
chart_datums =
session.view
|> render()
|> LazyHTML.from_fragment()
|> LazyHTML.query(
~s(#daily-scrobble-counts [data-chart-label="#{label}"][data-chart-value="#{expected_count}"])
)
assert Enum.count_until(chart_datums, 2) == 1
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