Compare commits
10 Commits
de576f49e9
...
e36d80818e
| Author | SHA1 | Date | |
|---|---|---|---|
| e36d80818e | |||
| 0bdffbc12a | |||
| e64f2b12c4 | |||
| e67b172d64 | |||
| b3b8f0546e | |||
| 611ca7e386 | |||
| 88d75afa3e | |||
| 3617f0e7b3 | |||
| 3e7a71f522 | |||
| 7a2c3e2d0d |
+1
-1
@@ -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 -->
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user