feat(discover): snooze affordance on Android + web suggestion cards — #2375
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user