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:
@@ -58,6 +58,7 @@ export const qk = {
|
||||
smtpConfig: () => ['smtpConfig'] as const,
|
||||
suggestions: (limit?: number) =>
|
||||
['suggestions', { limit: limit ?? 12 }] as const,
|
||||
suggestionSnoozes: () => ['suggestionSnoozes'] as const,
|
||||
home: () => ['home'] as const,
|
||||
albumsAlpha: () => ['albumsAlpha'] as const,
|
||||
artistTracks: (artistId: string) => ['artistTracks', artistId] as const,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
api: { get: vi.fn() }
|
||||
api: { get: vi.fn(), post: vi.fn(), del: vi.fn() }
|
||||
}));
|
||||
|
||||
import { listSuggestions } from './suggestions';
|
||||
import {
|
||||
listSuggestions,
|
||||
listSnoozes,
|
||||
snoozeSuggestion,
|
||||
unsnoozeSuggestion
|
||||
} from './suggestions';
|
||||
import { qk } from './queries';
|
||||
import { api } from './client';
|
||||
import type { ArtistSuggestion } from './types';
|
||||
import type { ArtistSuggestion, SuggestionSnooze } from './types';
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -40,3 +45,56 @@ describe('suggestions client', () => {
|
||||
expect(qk.suggestions(20)).toEqual(['suggestions', { limit: 20 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestion snoozes (#2375)', () => {
|
||||
test('snoozeSuggestion sends the name — the server 400s without it', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
await snoozeSuggestion('mb-1', 'Parked Artist');
|
||||
expect(api.post).toHaveBeenCalledWith('/api/discover/suggestions/mb-1/snooze', {
|
||||
name: 'Parked Artist'
|
||||
});
|
||||
});
|
||||
|
||||
test('snoozeSuggestion sends no days, leaving the default to the server', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
await snoozeSuggestion('mb-1', 'Parked Artist');
|
||||
const body = (api.post as ReturnType<typeof vi.fn>).mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body).not.toHaveProperty('days');
|
||||
});
|
||||
|
||||
// MBIDs are UUIDs today, but the column is free-text and the value comes
|
||||
// from an external similarity feed, so it goes through encodeURIComponent.
|
||||
test('the mbid is URL-encoded into the path', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
await snoozeSuggestion('weird/id?x', 'Odd');
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
'/api/discover/suggestions/weird%2Fid%3Fx/snooze',
|
||||
{ name: 'Odd' }
|
||||
);
|
||||
});
|
||||
|
||||
test('unsnoozeSuggestion DELETEs the same path', async () => {
|
||||
(api.del as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
await unsnoozeSuggestion('mb-1');
|
||||
expect(api.del).toHaveBeenCalledWith('/api/discover/suggestions/mb-1/snooze');
|
||||
});
|
||||
|
||||
test('listSnoozes hits the snoozes collection', async () => {
|
||||
const fixture: SuggestionSnooze[] = [
|
||||
{
|
||||
mbid: 'mb-1',
|
||||
name: 'Parked Artist',
|
||||
snoozed_until: '2026-11-01T00:00:00Z',
|
||||
created_at: '2026-08-03T00:00:00Z'
|
||||
}
|
||||
];
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fixture);
|
||||
const got = await listSnoozes();
|
||||
expect(api.get).toHaveBeenCalledWith('/api/discover/snoozes');
|
||||
expect(got).toEqual(fixture);
|
||||
});
|
||||
|
||||
test('qk.suggestionSnoozes key shape', () => {
|
||||
expect(qk.suggestionSnoozes()).toEqual(['suggestionSnoozes']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { api } from './client';
|
||||
import { qk } from './queries';
|
||||
import type { ArtistSuggestion } from './types';
|
||||
import type { ArtistSuggestion, SuggestionSnooze } from './types';
|
||||
|
||||
export async function listSuggestions(limit = 12): Promise<ArtistSuggestion[]> {
|
||||
return api.get<ArtistSuggestion[]>(`/api/discover/suggestions?limit=${limit}`);
|
||||
@@ -14,3 +14,36 @@ export function createSuggestionsQuery(limit = 12) {
|
||||
staleTime: 5 * 60_000 // 5 minutes — see M5c spec §5
|
||||
});
|
||||
}
|
||||
|
||||
// Parks a suggestion for the server's default period (90 days). `name` is
|
||||
// REQUIRED by the server and is not optional bookkeeping: candidates are
|
||||
// out-of-library, so there is no artists row to resolve a display name from
|
||||
// and the snooze list would have nothing to render. Omitting it is a 400.
|
||||
//
|
||||
// No `days` is sent. There is deliberately no duration UI yet — that knob is
|
||||
// slice 6 (#2377) — and hardcoding a value here would pin the default to the
|
||||
// client instead of the server that owns it.
|
||||
export async function snoozeSuggestion(mbid: string, name: string): Promise<void> {
|
||||
await api.post<null>(`/api/discover/suggestions/${encodeURIComponent(mbid)}/snooze`, { name });
|
||||
}
|
||||
|
||||
// Brings a parked suggestion back immediately. The server 404s an MBID that
|
||||
// was never snoozed; callers treat that as already-unsnoozed rather than as a
|
||||
// failure, since the end state the user asked for is the one they get.
|
||||
export async function unsnoozeSuggestion(mbid: string): Promise<void> {
|
||||
await api.del(`/api/discover/suggestions/${encodeURIComponent(mbid)}/snooze`);
|
||||
}
|
||||
|
||||
export async function listSnoozes(): Promise<SuggestionSnooze[]> {
|
||||
return api.get<SuggestionSnooze[]>('/api/discover/snoozes');
|
||||
}
|
||||
|
||||
export function createSnoozesQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.suggestionSnoozes(),
|
||||
queryFn: listSnoozes
|
||||
// No staleTime, unlike the suggestions query: this list is the only route
|
||||
// back to an un-snooze, so it must reflect a snooze made seconds ago
|
||||
// rather than a cached view of the world.
|
||||
});
|
||||
}
|
||||
|
||||
@@ -334,6 +334,16 @@ export type ArtistSuggestion = {
|
||||
image_url?: string; // resolved on-demand from Lidarr; absent → card placeholder
|
||||
};
|
||||
|
||||
// One parked suggestion — "not right now", not a dislike. The server only
|
||||
// ever returns rows whose snoozed_until is still in the future, so the client
|
||||
// never has to compare against the clock to decide what to show.
|
||||
export type SuggestionSnooze = {
|
||||
mbid: string;
|
||||
name: string;
|
||||
snoozed_until: string; // RFC3339
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// Mirrors internal/api/types.go HomePayload. All slices are non-null
|
||||
// per the server contract — empty sections render as [].
|
||||
export type HomePayload = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user