feat(discover): snooze affordance on Android + web suggestion cards — #2375
test-web / test (push) Failing after 37s
android / Build + lint + test (push) Failing after 1m42s

Completes the snooze from slice 3 (#2374), so it's now touchable on both
clients (rule #27 — the server side alone was never shippable).

Copy is "Not right now" everywhere, never a dislike (rule #101). The
parked list even says so out loud: "Nothing here counts against your
taste profile."

Both clients flip the card in place to a "Not right now" state with an
Undo, rather than yanking it out of the grid under the cursor. The row
leaves on the next refetch; the persistent way back is a parked-list
section below the deck. That list isn't optional garnish — a snoozed
candidate is by definition absent from the deck, so without it the
DELETE endpoint is unreachable.

Android routes the write through the offline MutationQueue per rule #100,
as ONE toggle kind (SUGGESTION_SNOOZE_TOGGLE) carrying the desired state
rather than two action kinds. That reuses the LIKE_TOGGLE collapse: a
queued snooze the user has since undone is dropped unsent instead of
replaying after the undo and re-hiding an artist they asked to see. The
collapse helper is now a pure top-level function so that rule is unit
tested rather than inferred.

The repository does NOT enqueue on a 4xx — a permanent rejection would
replay to the same failure and would raise a misleading "will sync when
online" hint. The common case is a 404 from un-snoozing a row that
already lapsed, which is the user's intended end state anyway.

Also: an empty deck used to have one meaning (no listening signal yet).
It can now also mean "you parked them all", so the empty copy branches —
telling that user to go listen to something would be wrong advice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 19:03:10 -04:00
co-authored by Claude Opus 5
parent 86af79bd2f
commit 6e39471a70
19 changed files with 1176 additions and 68 deletions
@@ -125,4 +125,47 @@ describe('DiscoverResultCard', () => {
});
expect(screen.queryByTestId('attribution')).not.toBeInTheDocument();
});
// --- snooze affordance (#2375) ---
test('snooze button appears only when onSnooze is supplied', () => {
// The Lidarr search results reuse this card and have nothing to park, so
// the affordance must not appear unconditionally.
render(DiscoverResultCard, {
props: { kind: 'artist', title: 'Outsider', state: 'requestable' }
});
expect(screen.queryByRole('button', { name: /not right now/i })).not.toBeInTheDocument();
});
test('snooze button calls onSnooze and reads as "not right now", never as a dislike', async () => {
const onSnooze = vi.fn();
render(DiscoverResultCard, {
props: { kind: 'artist', title: 'Outsider', state: 'requestable', onSnooze }
});
const btn = screen.getByRole('button', { name: /not right now — hide outsider for a while/i });
// Rule #101: the accessible name must carry no verdict on the music.
expect(btn.getAttribute('aria-label')).not.toMatch(/dislike|not for me|never|hate/i);
await fireEvent.click(btn);
expect(onSnooze).toHaveBeenCalledOnce();
});
test('snoozed state swaps Request for Undo and shows a status pill', async () => {
const onUnsnooze = vi.fn();
const onRequest = vi.fn();
render(DiscoverResultCard, {
props: {
kind: 'artist',
title: 'Outsider',
state: 'snoozed',
onRequest,
onUnsnooze
}
});
expect(screen.queryByRole('button', { name: /request outsider/i })).not.toBeInTheDocument();
const status = screen.getByRole('status');
expect(status.textContent).toMatch(/not right now/i);
await fireEvent.click(screen.getByRole('button', { name: /bring outsider back/i }));
expect(onUnsnooze).toHaveBeenCalledOnce();
expect(onRequest).not.toHaveBeenCalled();
});
});