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 = {
|
||||
|
||||
Reference in New Issue
Block a user