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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user