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 54ee8f02..03d1deb8 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: In Progress
+status: Done
assignee: []
created_date: '2026-04-20 09:32'
-updated_date: '2026-04-22 12:42'
+updated_date: '2026-04-22 13:27'
labels:
- ui
- scrobble
@@ -29,20 +29,38 @@ The Release component's scrobble interface has several usability gaps that make
## Acceptance Criteria
-- [ ] #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
+- [x] #1 The release-sheet header renders a `Finished at` date/time picker that displays 'Now' when unset and an explicit time when set
+- [x] #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
+- [x] #3 The release-sheet header renders a `⋯` overflow menu containing `Print tracklist`, plus `Connect Last.fm` when the session key is missing
+- [x] #4 The duplicate top-level `Tracks` master checkbox shown on single-medium releases is removed
+- [x] #5 Each medium header renders a soft-primary `Scrobble disc` button that is enabled regardless of cross-medium track selection
+- [x] #6 Each medium header renders a `⋯` overflow menu containing `Print tracklist` for that medium
+- [x] #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
+- [x] #8 The sticky bar is not rendered when no tracks are selected
+- [x] #9 `Scrobble selected` scrobbles only the ticked tracks using the picker value or `DateTime.utc_now()` when unset
+- [x] #10 Clicking a per-medium `Scrobble disc` submits that medium regardless of selection elsewhere, using the picker value or `DateTime.utc_now()`
+- [x] #11 The picker has a reset affordance that clears the value back to 'Now'
+- [x] #12 Disabled scrobble buttons are visibly distinct from enabled ones in both light and dark mode (verified in browser)
+- [x] #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
+- [x] #14 `ScrobbleLive.Show` per-medium scrobble handler works with tracks selected (regression test)
+- [x] #15 All new user-facing strings wrapped in gettext; `.pot`/`.po` files regenerated via `mix gettext.extract --merge`
+- [x] #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
+
+## Implementation Notes
+
+
+Implementation complete 2026-04-22.
+
+Changes:
+- `lib/music_library_web/components/release.ex` — header restructure (title + subtitle + picker + solid `Scrobble release` + ⋯ dropdown), `.medium/1` updated (removed selection-blocks-medium disable, label visible, print moved to ⋯ dropdown), new `.selection_bar/1` function component + private `selected_tracks_summary/2` helper, `finished_at` wired into the form via `parse_finished_at/1`, new `clear_finished_at` event handler, and all three scrobble handlers now resolve `socket.assigns.finished_at || DateTime.utc_now()` at call time.
+- `test/music_library_web/live/collection_live/show_test.exs` — updated assertion from "Connect your Last.fm account" to new "Connect Last.fm" link label.
+- `test/music_library_web/live/scrobble_live/show_test.exs` — regression test added: medium scrobble works with a cross-medium track selected.
+- `test/music_library_web/components/release_test.exs` — new file, 10 integration tests through `CollectionLive.Show` covering picker defaults, picker → handler arg propagation, reset-to-now, sticky bar render, cross-medium copy, and scrobble-selected using the picker value.
+- `priv/gettext/default.pot` + `priv/gettext/en/LC_MESSAGES/default.po` — regenerated via `mix gettext.extract --merge`.
+
+Verification:
+- `mise run dev:precommit` — all green (credo, sobelow, formatting, translations, 823 tests passing).
+- Browser-verified at :4003: desktop 1440px (4-disc release showed new header, per-medium buttons, sticky bar with cross-medium count), mobile 360px (header stacks to title + picker + Release button, medium scrobble collapses to icon-only), picker open/select/reset cycle worked, overflow menus rendered Print tracklist.
+- `grep -n "MapSet.size(@selected_tracks) > 0" lib/music_library_web/components/release.ex` returns only the sticky-bar visibility guard, as planned.
+
diff --git a/lib/music_library_web/components/release.ex b/lib/music_library_web/components/release.ex
index 610d5d5a..e492e0a3 100644
--- a/lib/music_library_web/components/release.ex
+++ b/lib/music_library_web/components/release.ex
@@ -14,13 +14,16 @@ defmodule MusicLibraryWeb.Components.Release do
@impl true
def mount(socket) do
+ current_time = DateTime.utc_now()
+
{:ok,
socket
|> assign(:can_scrobble?, ScrobbleActivity.can_scrobble?())
|> assign(:release_with_tracks, AsyncResult.loading())
|> assign(:already_scrobbled, false)
|> assign(:selected_tracks, MapSet.new())
- |> assign(:form, to_form(%{}, as: :release))
+ |> assign(:finished_at, current_time)
+ |> assign(:form, to_form(%{"finished_at" => current_time}, as: :release))
|> assign(:pending_form_params, nil)}
end
@@ -81,10 +84,30 @@ defmodule MusicLibraryWeb.Components.Release do
socket
|> assign(:selected_tracks, new_selected)
+ |> assign(:finished_at, parse_finished_at(params["finished_at"]))
|> assign(:pending_form_params, nil)
end
end
+ @spec parse_finished_at(term()) :: DateTime.t() | nil
+ defp parse_finished_at(nil), do: nil
+ defp parse_finished_at(""), do: nil
+
+ defp parse_finished_at(value) when is_binary(value) do
+ case DateTime.from_iso8601(value) do
+ {:ok, datetime, _offset} ->
+ datetime
+
+ {:error, _} ->
+ case NaiveDateTime.from_iso8601(value) do
+ {:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC")
+ {:error, _} -> nil
+ end
+ end
+ end
+
+ defp parse_finished_at(_), do: nil
+
@impl true
def render(assigns) do
~H"""
@@ -93,7 +116,7 @@ defmodule MusicLibraryWeb.Components.Release do
:if={@record.selected_release_id}
id={@sheet_id}
placement="right"
- class="min-w-xs sm:min-w-sm"
+ class="flex min-w-xs flex-col overflow-hidden p-0 sm:min-w-sm"
>
<.form
for={@form}
@@ -101,108 +124,142 @@ defmodule MusicLibraryWeb.Components.Release do
phx-target={@myself}
phx-change="validate"
phx-auto-recover="recover_form"
+ class="flex min-h-0 flex-1 flex-col"
>
-
-
+