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