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
@@ -1,10 +1,14 @@
<script lang="ts" module>
export type DiscoverCardKind = 'artist' | 'album' | 'track';
export type DiscoverCardState = 'requestable' | 'kept' | 'requested';
// 'snoozed' is a transient state the card flips to in place after the user
// parks it, so the disappearance is legible and undoable rather than a card
// silently vanishing from under the cursor (rule #24). The row is gone on
// the next refetch; the persistent way back is the snoozed list.
export type DiscoverCardState = 'requestable' | 'kept' | 'requested' | 'snoozed';
</script>
<script lang="ts">
import { Plus, Disc3, Album, Music2 } from 'lucide-svelte';
import { Plus, Disc3, Album, Music2, Clock } from 'lucide-svelte';
let {
kind,
@@ -14,6 +18,8 @@
state,
attribution,
onRequest,
onSnooze,
onUnsnooze,
}: {
kind: DiscoverCardKind;
title: string;
@@ -22,6 +28,10 @@
state: DiscoverCardState;
attribution?: string;
onRequest?: () => void;
// Omit both to get a card with no snooze affordance — the Lidarr search
// results reuse this component and have nothing to park.
onSnooze?: () => void;
onUnsnooze?: () => void;
} = $props();
const FallbackIcon = $derived(
@@ -65,20 +75,50 @@
<div class="badge-row" data-testid="badge-row">
{#if state === 'kept'}
<span class="kept-pill" role="status">Kept</span>
{:else if state === 'snoozed'}
<span class="snoozed-pill" role="status">Not right now</span>
{/if}
</div>
</div>
<div class="actions pt-3" data-testid="actions">
{#if state === 'requestable'}
{#if state === 'snoozed'}
<button
type="button"
aria-label={`Request ${title}`}
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
onclick={handleRequest}
aria-label={`Bring ${title} back`}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
onclick={() => onUnsnooze?.()}
>
<Plus size={16} strokeWidth={1} /> Request
Undo
</button>
{:else if state === 'requestable'}
<div class="flex items-center gap-2">
<button
type="button"
aria-label={`Request ${title}`}
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
onclick={handleRequest}
>
<Plus size={16} strokeWidth={1} /> Request
</button>
{#if onSnooze}
<!--
Icon-only to keep Request unambiguously the primary action, with
the intent carried by the accessible name. "Not right now" is the
whole point of the wording: this parks a suggestion, it does not
record an opinion about the artist (rule #101).
-->
<button
type="button"
aria-label={`Not right now — hide ${title} for a while`}
title="Not right now"
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
onclick={() => onSnooze?.()}
>
<Clock size={16} strokeWidth={1} />
</button>
{/if}
</div>
{:else if state === 'kept'}
<button
type="button"
@@ -131,4 +171,20 @@
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
}
/* Muted rather than accented: a parked card should recede, not compete
with the live suggestions around it. Same geometry as .kept-pill so the
two read as one component in different states. */
.snoozed-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 14px;
background: var(--fs-slate);
color: var(--fs-vellum);
}
.card[data-state='snoozed'] {
opacity: 0.6;
}
</style>
@@ -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();
});
});
+124 -7
View File
@@ -1,23 +1,49 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { createSuggestionsQuery } from '$lib/api/suggestions';
import {
createSuggestionsQuery,
createSnoozesQuery,
snoozeSuggestion,
unsnoozeSuggestion
} from '$lib/api/suggestions';
import { createRequest } from '$lib/api/requests';
import { qk } from '$lib/api/queries';
import { pushToast } from '$lib/stores/toast.svelte';
import DiscoverResultCard from './DiscoverResultCard.svelte';
import type { ArtistSuggestion, SeedContribution } from '$lib/api/types';
import type { ArtistSuggestion, SeedContribution, SuggestionSnooze } from '$lib/api/types';
const client = useQueryClient();
const queryStore = createSuggestionsQuery();
const query = $derived($queryStore);
const suggestions = $derived((query.data ?? []) as ArtistSuggestion[]);
const snoozeStore = createSnoozesQuery();
const snoozeQuery = $derived($snoozeStore);
const snoozes = $derived((snoozeQuery.data ?? []) as SuggestionSnooze[]);
// Track MBIDs the user just requested so the card flips immediately.
let optimisticRequested = $state(new Set<string>());
// Snoozed-just-now MBIDs. These keep their card in place showing an Undo,
// rather than yanking it out of the grid under the cursor — the card is
// gone on the next refetch, and the snoozed list below is the way back
// after that.
let optimisticSnoozed = $state(new Set<string>());
function visible(s: ArtistSuggestion): boolean {
return !optimisticRequested.has(s.mbid);
}
function cardState(s: ArtistSuggestion): 'requestable' | 'snoozed' {
return optimisticSnoozed.has(s.mbid) ? 'snoozed' : 'requestable';
}
function withMbid(set: Set<string>, mbid: string, present: boolean): Set<string> {
const next = new Set(set);
if (present) next.add(mbid);
else next.delete(mbid);
return next;
}
function attributionText(attribution: SeedContribution[]): string {
if (attribution.length === 0) return '';
const verb = (s: SeedContribution) => (s.is_liked ? 'liked' : 'played');
@@ -32,6 +58,19 @@
return `Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.`;
}
// "in 3 months" / "in 12 days" — a relative phrase, because the exact
// calendar date of a 90-day snooze is noise the user never asked for.
function returnsIn(snoozedUntil: string): string {
const ms = new Date(snoozedUntil).getTime() - Date.now();
if (!Number.isFinite(ms) || ms <= 0) return 'shortly';
const days = Math.round(ms / 86_400_000);
if (days < 1) return 'today';
if (days === 1) return 'tomorrow';
if (days < 45) return `in ${days} days`;
const months = Math.round(days / 30);
return months === 1 ? 'in about a month' : `in about ${months} months`;
}
async function onRequest(s: ArtistSuggestion) {
try {
await createRequest({
@@ -39,9 +78,7 @@
lidarr_artist_mbid: s.mbid,
artist_name: s.name
});
const next = new Set(optimisticRequested);
next.add(s.mbid);
optimisticRequested = next;
optimisticRequested = withMbid(optimisticRequested, s.mbid, true);
// The server-side filter hides this candidate on next refetch.
await client.invalidateQueries({ queryKey: qk.suggestions() });
} catch {
@@ -49,6 +86,39 @@
// stays requestable so the user can retry.
}
}
async function onSnooze(s: ArtistSuggestion) {
// Flip first so the tap feels instant, then reconcile. On failure the
// card goes back to requestable and says so — a snooze that silently
// did nothing would leave the user tapping it again.
optimisticSnoozed = withMbid(optimisticSnoozed, s.mbid, true);
try {
await snoozeSuggestion(s.mbid, s.name);
await client.invalidateQueries({ queryKey: qk.suggestionSnoozes() });
} catch {
optimisticSnoozed = withMbid(optimisticSnoozed, s.mbid, false);
pushToast(`Couldn't hide ${s.name}`, 'error');
}
}
async function onUnsnooze(mbid: string, name: string) {
optimisticSnoozed = withMbid(optimisticSnoozed, mbid, false);
try {
await unsnoozeSuggestion(mbid);
} catch (e) {
// 404 means it wasn't snoozed after all — the user's intended end
// state, so it isn't an error worth showing them.
if ((e as { status?: number })?.status !== 404) {
optimisticSnoozed = withMbid(optimisticSnoozed, mbid, true);
pushToast(`Couldn't bring ${name} back`, 'error');
return;
}
}
await Promise.all([
client.invalidateQueries({ queryKey: qk.suggestionSnoozes() }),
client.invalidateQueries({ queryKey: qk.suggestions() })
]);
}
</script>
<div>
@@ -58,7 +128,16 @@
</header>
{#if !query.isPending && suggestions.length === 0}
<p class="text-text-secondary">Listen to something or like an artist to start getting suggestions.</p>
<!--
An empty deck used to mean one thing — no listening signal yet. With
snoozing it can also mean "you parked them all", and telling that user to
go listen to something would be wrong advice.
-->
<p class="text-text-secondary">
{snoozes.length === 0
? 'Listen to something or like an artist to start getting suggestions.'
: "Nothing new right now — the artists you've parked are below."}
</p>
{:else if suggestions.length > 0}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each suggestions.filter(visible) as s (s.mbid)}
@@ -66,11 +145,49 @@
kind="artist"
title={s.name}
imageUrl={s.image_url}
state="requestable"
state={cardState(s)}
attribution={attributionText(s.attribution)}
onRequest={() => onRequest(s)}
onSnooze={() => onSnooze(s)}
onUnsnooze={() => onUnsnooze(s.mbid, s.name)}
/>
{/each}
</div>
{/if}
<!--
The snoozed list is not a nicety: a parked suggestion is by definition
absent from the deck above, so without this there is no way back. Only
rendered when non-empty, so the surface stays quiet for the common case.
-->
{#if snoozes.length > 0}
<section class="mt-8 border-t border-border pt-6" aria-labelledby="snoozed-heading">
<h3 id="snoozed-heading" class="font-display text-lg font-medium text-text-primary">
Not right now
</h3>
<p class="mt-1 text-sm text-text-secondary">
These come back on their own. Nothing here counts against your taste profile.
</p>
<ul class="mt-3 divide-y divide-border">
{#each snoozes as snoozed (snoozed.mbid)}
<li class="flex items-center justify-between gap-4 py-2">
<div class="min-w-0">
<div class="truncate text-sm text-text-primary">{snoozed.name}</div>
<div class="text-xs text-text-secondary">
Back {returnsIn(snoozed.snoozed_until)}
</div>
</div>
<button
type="button"
aria-label={`Bring ${snoozed.name} back now`}
class="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
onclick={() => onUnsnooze(snoozed.mbid, snoozed.name)}
>
Bring back
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
+142 -20
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
@@ -9,17 +9,30 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
});
vi.mock('$lib/api/suggestions', () => ({
createSuggestionsQuery: vi.fn()
createSuggestionsQuery: vi.fn(),
createSnoozesQuery: vi.fn(),
snoozeSuggestion: vi.fn().mockResolvedValue(undefined),
unsnoozeSuggestion: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('$lib/api/requests', () => ({
createRequest: vi.fn().mockResolvedValue({})
}));
const pushToastMock = vi.fn();
vi.mock('$lib/stores/toast.svelte', () => ({
pushToast: (...args: unknown[]) => pushToastMock(...args)
}));
import SuggestionFeed from './SuggestionFeed.svelte';
import { createSuggestionsQuery } from '$lib/api/suggestions';
import {
createSuggestionsQuery,
createSnoozesQuery,
snoozeSuggestion,
unsnoozeSuggestion
} from '$lib/api/suggestions';
import { createRequest } from '$lib/api/requests';
import type { ArtistSuggestion } from '$lib/api/types';
import type { ArtistSuggestion, SuggestionSnooze } from '$lib/api/types';
const oneSeed: ArtistSuggestion = {
mbid: 'mb1',
@@ -51,46 +64,50 @@ const threeSeeds: ArtistSuggestion = {
]
};
/** Days from now as an RFC3339 string, for snooze fixtures. */
function inDays(n: number): string {
return new Date(Date.now() + n * 86_400_000).toISOString();
}
function setSuggestions(data: ArtistSuggestion[]) {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data }));
}
function setSnoozes(data: SuggestionSnooze[]) {
(createSnoozesQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data }));
}
beforeEach(() => setSnoozes([]));
afterEach(() => vi.clearAllMocks());
describe('SuggestionFeed', () => {
test('renders one card per suggestion', () => {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [oneSeed, twoSeeds] })
);
setSuggestions([oneSeed, twoSeeds]);
render(SuggestionFeed);
expect(screen.getByText('Outsider')).toBeInTheDocument();
expect(screen.getByText('Outsider Two')).toBeInTheDocument();
});
test('attribution copy: 1 seed → "Because you liked X."', () => {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [oneSeed] })
);
setSuggestions([oneSeed]);
render(SuggestionFeed);
expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument();
});
test('attribution copy: 2 seeds → "Because you liked A and played B."', () => {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [twoSeeds] })
);
setSuggestions([twoSeeds]);
render(SuggestionFeed);
expect(screen.getByText(/because you liked a and played b\./i)).toBeInTheDocument();
});
test('attribution copy: 3 seeds → Oxford comma', () => {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [threeSeeds] })
);
setSuggestions([threeSeeds]);
render(SuggestionFeed);
expect(screen.getByText(/because you liked x, played y, and played z\./i)).toBeInTheDocument();
});
test('Request button calls createRequest with artist-kind body', async () => {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [oneSeed] })
);
setSuggestions([oneSeed]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /request outsider/i }));
expect(createRequest).toHaveBeenCalledWith({
@@ -102,8 +119,113 @@ describe('SuggestionFeed', () => {
});
test('empty state when data is []', () => {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
setSuggestions([]);
render(SuggestionFeed);
expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument();
});
});
describe('SuggestionFeed snooze (#2375)', () => {
test('snooze sends BOTH mbid and name — the server 400s without the name', async () => {
setSuggestions([oneSeed]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
expect(snoozeSuggestion).toHaveBeenCalledWith('mb1', 'Outsider');
});
test('the card stays in place showing Undo, rather than vanishing', async () => {
setSuggestions([oneSeed]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
// Still on screen — the disappearance happens on refetch, not under the
// cursor (rule #24).
expect(screen.getByText('Outsider')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /bring outsider back/i })).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Not right now');
});
test('a failed snooze reverts the card and says so', async () => {
(snoozeSuggestion as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('offline'));
setSuggestions([oneSeed]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
// Back to requestable — a snooze that silently did nothing would leave
// the user tapping it again.
expect(screen.getByRole('button', { name: /request outsider/i })).toBeInTheDocument();
expect(pushToastMock).toHaveBeenCalledWith("Couldn't hide Outsider", 'error');
});
test('undo on the card calls unsnoozeSuggestion', async () => {
setSuggestions([oneSeed]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
await fireEvent.click(screen.getByRole('button', { name: /bring outsider back/i }));
expect(unsnoozeSuggestion).toHaveBeenCalledWith('mb1');
});
test('the snoozed list is the way back once the card is gone', async () => {
// Deck empty, one parked artist: exactly the state after a refetch.
setSuggestions([]);
setSnoozes([
{ mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
]);
render(SuggestionFeed);
expect(screen.getByRole('heading', { name: /not right now/i })).toBeInTheDocument();
expect(screen.getByText('Parked')).toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i }));
expect(unsnoozeSuggestion).toHaveBeenCalledWith('mbX');
});
test('a 404 from unsnooze is not surfaced as an error', async () => {
(unsnoozeSuggestion as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ status: 404 });
setSuggestions([]);
setSnoozes([
{ mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i }));
// Already-unsnoozed IS the end state the user asked for.
expect(pushToastMock).not.toHaveBeenCalled();
});
test('a non-404 unsnooze failure does surface', async () => {
(unsnoozeSuggestion as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ status: 500 });
setSuggestions([]);
setSnoozes([
{ mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i }));
expect(pushToastMock).toHaveBeenCalledWith("Couldn't bring Parked back", 'error');
});
test('return time reads as a relative phrase, not a calendar date', () => {
setSuggestions([]);
setSnoozes([
{ mbid: 'a', name: 'Quarter', snoozed_until: inDays(90), created_at: inDays(0) },
{ mbid: 'b', name: 'Fortnight', snoozed_until: inDays(14), created_at: inDays(0) }
]);
render(SuggestionFeed);
expect(screen.getByText(/back in about 3 months/i)).toBeInTheDocument();
expect(screen.getByText(/back in 14 days/i)).toBeInTheDocument();
});
test('no snoozed section when nothing is parked', () => {
setSuggestions([oneSeed]);
setSnoozes([]);
render(SuggestionFeed);
expect(screen.queryByRole('heading', { name: /not right now/i })).not.toBeInTheDocument();
});
// An empty deck has two causes now, and the advice differs. Telling someone
// who parked everything to go listen to music would be wrong.
test('empty-deck copy distinguishes "no signal" from "you parked them all"', () => {
setSuggestions([]);
setSnoozes([
{ mbid: 'a', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
]);
render(SuggestionFeed);
expect(screen.getByText(/nothing new right now/i)).toBeInTheDocument();
expect(screen.queryByText(/listen to something or like an artist/i)).not.toBeInTheDocument();
});
});