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
View File
@@ -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,
+61 -3
View File
@@ -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']);
});
});
+34 -1
View File
@@ -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.
});
}
+10
View File
@@ -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 = {