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
+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>