From 70b51b379042475acba2458d9890793e0aa1790a Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Wed, 22 Apr 2026 13:37:35 +0100 Subject: [PATCH] ML-142: Accepted mockup and plan --- backlog/ml-142/mockups.html | 534 ++++++++++++++++++ backlog/ml-142/plan.md | 183 ++++++ ...ve-scrobble-UI-in-the-Release-component.md | 29 +- 3 files changed, 741 insertions(+), 5 deletions(-) create mode 100644 backlog/ml-142/mockups.html create mode 100644 backlog/ml-142/plan.md diff --git a/backlog/ml-142/mockups.html b/backlog/ml-142/mockups.html new file mode 100644 index 00000000..2c29ccf7 --- /dev/null +++ b/backlog/ml-142/mockups.html @@ -0,0 +1,534 @@ + + +

Option A — v2, app-styled + mobile layout + explicit "Scrobble release"

+

Addresses the three iteration asks: using the app's zinc/blue palette and Fluxon button variants, dedicated mobile layout, and a clear top-level "Scrobble release" action.

+ +
+
Action hierarchy
+
+ Header action + + Scrobble release + all tracks, one submission +
+
+ Per-medium action + + Scrobble disc + tracks of that medium +
+
+ Selection action + + Scrobble selected + only ticked rows — appears only when something is ticked +
+
+ +
+ + +
+
+ Desktop · sheet width ~520px + Fluxon variants: solid primary / soft primary / outline +
+
+
+
+
+
+
Kid A Mnesia
+
Radiohead · 2 CDs · 2021
+
+
+ + + Finished at + Now · Apr 22, 14:30 + + + + +
+
+
+ +
+
+
+ + Disc 1 + CD +
+
+ + +
+
+ +
+ + 1 + How to Disappear Completely + 5:56 +
+
+ + 2 + The National Anthem + 5:51 +
+
+ + 3 + Everything in Its Right Place + 4:11 +
+ +
+
+ + Disc 2 + CD +
+
+ + +
+
+ +
+ + 1 + Pyramid Song + 4:49 +
+
+ + 2 + Idioteque + 5:09 +
+
+ + 3 + Like Spinning Plates + 3:57 +
+ +
+
+ 2 tracks selected + across 2 discs · 10:45 +
+ +
+
+
+
+ +
+ Scrobble release sits in the header beside the "Finished at" picker. Solid primary to mark the default action. Per-medium Scrobble disc uses the soft variant to stay a visible shortcut without competing with the primary. +
+
+ + +
+
+ Mobile · 360px width + iPhone SE class +
+
+
+
+
+
+
+
Kid A Mnesia
+
Radiohead · 2 CDs
+
+ +
+ +
+ + + + + Finished at + Now + + + + + +
+
+
+ +
+
+
+ + Disc 1 + CD +
+
+ + +
+
+ +
+ + 1 + How to Disappear Completely + 5:56 +
+
+ + 2 + The National Anthem + 5:51 +
+ +
+
+ + Disc 2 + CD +
+
+ + +
+
+ +
+ + 1 + Pyramid Song + 4:49 +
+ +
+
+ 2 selected + 10:45 +
+ +
+
+
+
+ +
+ Mobile reflow: title row stacks title + ⋯; the picker and "Release" button share a full-width row (picker grows, button hugs). Per-medium scrobble becomes icon-only to fit. Sticky bar label shortens to "Scrobble" + count + duration. +
+
+
+ +
+ Design language notes
+ +
diff --git a/backlog/ml-142/plan.md b/backlog/ml-142/plan.md new file mode 100644 index 00000000..c98a83c4 --- /dev/null +++ b/backlog/ml-142/plan.md @@ -0,0 +1,183 @@ +# Improve scrobble UI in the Release component + +## Context + +The Release sheet's scrobble controls have three concrete friction points, confirmed on review: + +1. **Navigation.** On a multi-medium release, the only way to scrobble a cross-medium selection is the top-level button — users have to scroll all the way back up after picking tracks on disc 3. +2. **No picker for `finished_at`.** The timestamp is always `DateTime.utc_now()`; there is no UI to backfill a listening session. `ScrobbleActivity.scrobble_release/3`, `scrobble_medium/4`, and `scrobble_tracks/4` already accept a `DateTime`, so this is purely a UI gap. +3. **Disabled buttons aren't visually distinct.** All scrobble buttons use `variant="soft"` with only `opacity-70` for disabled — hard to tell state apart. The per-medium button is *always* disabled when any track is selected anywhere, which is surprising. + +A fourth issue surfaced in review: the **print** icon is omnipresent (release + every medium) but rarely used, taking up valuable primary-action space. + +The accepted UX direction (Option A, see `backlog/ml-142/mockups.html`) is a three-tier action hierarchy: + +- **Header → whole release** (primary action, solid button) +- **Per-medium → that disc** (soft shortcut, always enabled) +- **Sticky footer → selected tracks** (appears only when tracks are ticked) + +A single "Finished at" picker in the header drives all three, defaulting to "Now" (nil) until changed. Print moves into a `⋯` overflow menu on both the release header and each medium header. + +## Scope + +### In scope + +- `lib/music_library_web/components/release.ex` — the LiveComponent and the shared `.medium/1` / `.track_list/1` function components it exports. +- `lib/music_library_web/live/scrobble_live/show.ex` — the standalone scrobble page, which imports `.medium/1`; behaviour must stay coherent with the new component contract (the selection-blocks-medium-scrobble bug is removed there too). +- New user-facing strings wrapped in `gettext`; `.pot`/`.po` regenerated. +- Tests: new coverage for the Release LiveComponent's event handlers and the sticky-bar rendering, plus an updated handler test for `ScrobbleLive.Show` confirming medium-scrobble now works with tracks selected. + +### Out of scope + +- `MusicLibrary.ScrobbleActivity` — no context changes. All three functions already take a `DateTime`. +- A full sticky-bar + picker port to `ScrobbleLive.Show`. That page uses its own layout (not the sheet) and can be iterated on separately once the LiveComponent pattern is settled. +- Changes to `ScrobbleComponents` function module — it isn't used by the Release sheet. +- No database, migration, Oban, or JS-hook work. + +## Implementation + +### 1. Header restructure (`release.ex:89-164`) + +Replace the current flex row ("Tracks" label + print + scrobble + auth) with a two-block structure that reflows on narrow viewports: + +``` +
+
+ {release_title} + {release_subtitle} (# of mediums · year) +
+
+ <.date_time_picker_pill ... /> + <.button variant="solid" phx-click="scrobble_release" ...> + ▶ Scrobble release + + <.dropdown> + <.button variant="outline" size="sm">⋯ + <:item><.dropdown_link phx-click="print_tracklist">Print tracklist + <:item :if={!@can_scrobble?}><.dropdown_link href={LastFm.auth_url()}>Connect Last.fm + +
+
+``` + +- Drop the existing single-medium "Tracks" master checkbox (it duplicates the medium-level checkbox that's always rendered now). +- Mobile (`< sm:`): the wrapping grid collapses so the title row holds title + `⋯`, and the second row holds the picker (flex-1) + "Scrobble release" (hug contents). +- The "Scrobble release" button is `variant="solid"` (Fluxon's primary colour) — this is the contrast fix mandated by AC#2 on the primary flow. + +### 2. `.medium/1` function component (`release.ex:221-283`) + +Three changes: + +- **Remove the selection-based disable** on the scrobble button (`release.ex:260`): `disabled={@already_scrobbled || MapSet.size(@selected_tracks) > 0}` → `disabled={@already_scrobbled}`. This is the AC#3 unblock. +- **Add a label** to the scrobble button ("Scrobble disc" / "Scrobble side" via `medium_scrobble_label/1`, which already exists). Keep `variant="soft"`, `color="primary"` — visually secondary to the release button but with colour anchoring it as "this is scrobbling". +- **Replace the separate print button** with a `.dropdown` trigger identical in shape to the header's `⋯` menu; print lives inside. Keeps one row per medium without losing the feature. + +The function-component signature stays backward-compatible: the existing `myself` / `already_scrobbled` / `selected_tracks` attrs are retained. `ScrobbleLive.Show` renders this component unchanged, so the fix propagates there automatically. + +### 3. Sticky selection bar (new, inside `release.ex` render) + +Rendered at the bottom of the sheet body, conditional on `MapSet.size(@selected_tracks) > 0`: + +``` +
+
+ {count} tracks selected + across {medium_count} discs · {duration} +
+ <.button variant="solid" phx-click="scrobble_selected_tracks" ...> + ▶ Scrobble selected + +
+``` + +Helpers (private, in `release.ex`): + +- `selected_tracks_summary/2` — takes `%{release, tracks: [...]}`and `MapSet` → returns `{count, medium_count, total_duration_ms}`. Reuses the existing `Release.tracks/1` flattening. +- Duration formatted via the existing `MusicLibraryWeb.Duration.format_milliseconds/1`. + +The sticky element sits inside the scrollable sheet body so it pins to the bottom of the visible area, not the viewport. + +### 4. `finished_at` wiring + +Add a `finished_at` field to the form data in `release.ex`: + +- `mount/1`: initial form is `to_form(%{"selected_tracks" => [], "toggle_medium" => [], "finished_at" => nil})`. +- `handle_event("validate", params, socket)` (already exists, lines 408-423): extend `apply_form_params/3` to parse `params["release"]["finished_at"]` into `DateTime.t() | nil` (ISO8601 via `DateTime.from_iso8601/1`, nil on blank or parse failure) and assign under `:finished_at`. +- The three scrobble handlers (`scrobble_release`, `scrobble_medium`, `scrobble_selected_tracks`) resolve the timestamp at call time: + + ```elixir + finished_at = socket.assigns.finished_at || DateTime.utc_now() + ``` + + Then pass it as the existing `:finished_at` argument to `ScrobbleActivity.*`. + +Rendering the control: use Fluxon's `<.date_time_picker field={@form[:finished_at]} label={gettext("Finished at")} display_format="%b %-d, %H:%M" />`, already used in `record_form.ex:57-63`. When the field is nil, the picker shows its placeholder; rendering "Now" as the displayed value when `@finished_at` is nil is done via a small wrapper (`finished_at_display/1`) rather than the picker internals, so the behaviour is obvious in the template. + +A **Reset to now** affordance: if Fluxon's picker exposes a clear action (likely via its popover footer — confirm in browser), use it; otherwise mimic pattern used in `MusicLibraryWeb.StatsLive.Index` for the `on_this_day/1` component, which adds a button next to the picker that pushes a `"clear_finished_at"` event which `assign(:finished_at, nil)` and updates the form. + +### 5. Event handlers — summary of changes + +| Event | Before | After | +|---|---|---| +| `scrobble_release` | `DateTime.utc_now()` hardcoded | `@finished_at || DateTime.utc_now()` | +| `scrobble_medium` | disabled when selection present; always `utc_now()` | always enabled; uses `@finished_at || utc_now()` | +| `scrobble_selected_tracks` | `DateTime.utc_now()` hardcoded | `@finished_at || DateTime.utc_now()` | +| `clear_finished_at` | — | new; resets form field + assign | +| `validate` | parses `selected_tracks` + `toggle_medium` | also parses `finished_at` | + +The `already_scrobbled` 3-second lockout stays as-is. + +### 6. `ScrobbleLive.Show` — minimal updates + +`lib/music_library_web/live/scrobble_live/show.ex`: + +- The imported `.medium/1` renders the new "Scrobble disc" label + `⋯` dropdown; since this page has its own print handlers already (`"print_medium_tracklist"`), wire the dropdown `phx-click` to those. +- `handle_event("scrobble_medium", ...)` loses no behaviour — it already doesn't gate on selection (the guard was only in the component). Verify and add a regression test. +- No picker, no sticky bar added in this pass — those stay scoped to the LiveComponent. The page continues to use `DateTime.utc_now()`. + +### 7. Gettext + +New strings (all under existing `MusicLibraryWeb.Gettext`): + +- `"Finished at"`, `"Now"`, `"Reset to now"` +- `"Scrobble release"`, `"Scrobble disc"`, `"Scrobble selected"` +- ngettext: `"%{count} track selected"` / `"%{count} tracks selected"` +- `"across %{count} discs"` — may already exist; reuse if so +- `"More actions"` — for the `⋯` button's screen-reader label + +Run `mix gettext.extract --merge` after implementation. + +## Files touched + +- `lib/music_library_web/components/release.ex` — the primary change (render, handlers, helpers). +- `lib/music_library_web/live/scrobble_live/show.ex` — dropdown wiring for print; regression test for medium-scrobble-with-selection. +- `test/music_library_web/components/release_test.exs` — **new file**. Covers: + - Picker: default "Now" render; setting a value persists; `clear_finished_at` resets to nil. + - Sticky bar: hidden when no selection; visible with accurate count/duration when tracks ticked. + - `scrobble_release`, `scrobble_medium`, `scrobble_selected_tracks` each respect the picker value (assert the arg passed to `ScrobbleActivity` via a `Mox`-style expect or by stubbing `LastFm` via `Req.Test` and asserting on the scrobbled timestamps). + - Medium button is clickable (not disabled) when cross-medium selection is present. +- `test/music_library_web/live/scrobble_live/show_test.exs` — update / add regression asserting `scrobble_medium` works with selection. +- `priv/gettext/*.pot`, `priv/gettext/*/LC_MESSAGES/*.po` — regenerated by `mix gettext.extract --merge`. + +No other files expected. Confirmed: `scrobble_components.ex` is not used by the Release sheet. + +## Verification + +1. `mise run dev:precommit` — format, Credo, Sobelow, ExSlop, gettext check, tests. Must pass. +2. Manual browser checks with the dev server running. Required for UI work per project convention: + - **Desktop (~1200px, light + dark mode):** open a 2-CD release in Collection. Header shows picker + "Scrobble release" + `⋯`. Per-medium rows show "Scrobble disc" + `⋯`. Print lives only inside `⋯` menus. Contrast: solid release button is obviously different from disabled state (3-second lockout after scrobble). + - **Mobile (360px):** header stacks — title+⋯ row, then picker+"Release" button row. Per-medium button collapses to icon-only. Sticky bar still readable (no horizontal overflow). + - **Cross-medium scrobble:** tick one track on disc 1, one on disc 2 → sticky bar appears "2 tracks selected across 2 discs · 10:45" → click "Scrobble selected" → toast, sticky bar disappears. + - **Per-medium scrobble with selection present:** tick a track on disc 1 → click "Scrobble disc" on disc 2 → disc 2 scrobbles (unaffected by selection). Verify via `/dev/oban` or Last.fm. + - **Custom finished_at:** pick a time yesterday → click "Scrobble release" → verify the scrobbled timestamp in Last.fm matches the picked time (minus release duration for started_at). + - **Reset to now:** set a custom time → click reset → picker shows "Now" → scrobble uses `utc_now()`. +3. `mix test test/music_library_web/components/release_test.exs` — new LiveComponent tests pass. +4. `mix test test/music_library_web/live/scrobble_live/show_test.exs` — regression test for medium-scrobble-with-selection passes. +5. `grep -n "MapSet.size(@selected_tracks) > 0" lib/music_library_web/components/release.ex` — should return nothing except the sticky-bar visibility guard (which is intended). + +## Open items deferred to implementation + +- Exact Tailwind classes for the sticky bar shadow/padding — start with the mockup values, tune in the browser. +- Pluralisation of "across N discs" — if the release is single-medium, the subtitle should drop the "across 1 discs" clause and show just the duration. Small conditional in `selected_tracks_summary/2`. +- Whether to show the picker on releases where `@can_scrobble?` is false. Recommend hiding it in that case (no point picking a time if you can't submit) — to confirm in review. diff --git a/backlog/tasks/ml-142 - Improve-scrobble-UI-in-the-Release-component.md b/backlog/tasks/ml-142 - Improve-scrobble-UI-in-the-Release-component.md index c45d8115..54ee8f02 100644 --- a/backlog/tasks/ml-142 - Improve-scrobble-UI-in-the-Release-component.md +++ b/backlog/tasks/ml-142 - Improve-scrobble-UI-in-the-Release-component.md @@ -1,10 +1,10 @@ --- id: ML-142 title: Improve scrobble UI in the Release component -status: To Do +status: In Progress assignee: [] created_date: '2026-04-20 09:32' -updated_date: '2026-04-20 09:35' +updated_date: '2026-04-22 12:42' labels: - ui - scrobble @@ -12,7 +12,13 @@ dependencies: [] references: - lib/music_library_web/live/components/release.ex - lib/music_library_web/components/scrobble_components.ex + - backlog/ml-142/mockups.html + - backlog/ml-142/plan.md +documentation: + - backlog/ml-142/plan.md + - backlog/ml-142/mockups.html priority: medium +ordinal: 1000 --- ## Description @@ -23,7 +29,20 @@ The Release component's scrobble interface has several usability gaps that make ## Acceptance Criteria -- [ ] #1 Users can set a custom `finished_at` datetime before scrobbling, defaulting to the current time when not set -- [ ] #2 Scrobble buttons have sufficient contrast in both their enabled and disabled states so users can clearly distinguish between the two -- [ ] #3 Enable scrobbling of selected tracks from the closest scrabble button (i.e. the medium button) instead of always having to scrobble to the top one. +- [ ] #1 The release-sheet header renders a `Finished at` date/time picker that displays 'Now' when unset and an explicit time when set +- [ ] #2 The release-sheet header renders a solid-primary `Scrobble release` button that scrobbles the whole release using the picker value or `DateTime.utc_now()` when unset +- [ ] #3 The release-sheet header renders a `⋯` overflow menu containing `Print tracklist`, plus `Connect Last.fm` when the session key is missing +- [ ] #4 The duplicate top-level `Tracks` master checkbox shown on single-medium releases is removed +- [ ] #5 Each medium header renders a soft-primary `Scrobble disc` button that is enabled regardless of cross-medium track selection +- [ ] #6 Each medium header renders a `⋯` overflow menu containing `Print tracklist` for that medium +- [ ] #7 A sticky bar appears at the bottom of the sheet body whenever `MapSet.size(@selected_tracks) > 0`, showing track count, medium count, aggregate duration, and a `Scrobble selected` button +- [ ] #8 The sticky bar is not rendered when no tracks are selected +- [ ] #9 `Scrobble selected` scrobbles only the ticked tracks using the picker value or `DateTime.utc_now()` when unset +- [ ] #10 Clicking a per-medium `Scrobble disc` submits that medium regardless of selection elsewhere, using the picker value or `DateTime.utc_now()` +- [ ] #11 The picker has a reset affordance that clears the value back to 'Now' +- [ ] #12 Disabled scrobble buttons are visibly distinct from enabled ones in both light and dark mode (verified in browser) +- [ ] #13 On viewports ≤ 380px the header reflows: title row above a second row containing the picker (flex-1) and the `Scrobble release` button; per-medium scrobble collapses to icon-only; sticky bar stays legible +- [ ] #14 `ScrobbleLive.Show` per-medium scrobble handler works with tracks selected (regression test) +- [ ] #15 All new user-facing strings wrapped in gettext; `.pot`/`.po` files regenerated via `mix gettext.extract --merge` +- [ ] #16 New LiveComponent tests cover: default picker state, picker value propagating to all three scrobble handlers, sticky-bar rendering, and medium-button enabled-with-selection