"Matches your taste in shoegaze and dream pop." replaces the seed
attribution when the candidate's own tags overlap the taste profile.
The preference order is the point of slice 6: the tag reason describes the
MUSIC ("sounds like what you like"), while seed attribution describes the
graph ("adjacent to something you played"). When we can say the former, it
is strictly the better explanation. When we can't — the common case, since
tag coverage for out-of-library artists is partial by nature (#2376) — the
card falls back to attribution rather than going blank.
Both clients share the wording, Oxford comma included, and both have tests
asserting the exact strings. That's deliberate: identical copy across two
codebases silently diverges unless something fails when it does.
Android caps at 3 tags client-side even though the server already does.
The server contract could widen; a run-on subtitle shouldn't be how we
find out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
213 lines
8.6 KiB
Svelte
213 lines
8.6 KiB
Svelte
<script lang="ts">
|
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
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, 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;
|
|
}
|
|
|
|
// "Matches your taste in shoegaze and dream pop." — the reason line when the
|
|
// candidate's own tags overlap the taste profile (#2377). Preferred over seed
|
|
// attribution because it describes the MUSIC ("sounds like what you like")
|
|
// rather than the graph ("adjacent to something you played"), which is the
|
|
// whole point of slice 6. Falls back when there are no matched tags, which is
|
|
// the common case: coverage is partial by nature (#2376).
|
|
function reasonText(s: ArtistSuggestion): string {
|
|
const tags = s.matched_tags ?? [];
|
|
if (tags.length === 0) return attributionText(s.attribution);
|
|
if (tags.length === 1) return `Matches your taste in ${tags[0]}.`;
|
|
if (tags.length === 2) return `Matches your taste in ${tags[0]} and ${tags[1]}.`;
|
|
return `Matches your taste in ${tags[0]}, ${tags[1]}, and ${tags[2]}.`;
|
|
}
|
|
|
|
function attributionText(attribution: SeedContribution[]): string {
|
|
if (attribution.length === 0) return '';
|
|
const verb = (s: SeedContribution) => (s.is_liked ? 'liked' : 'played');
|
|
const phrases = attribution.map((s) => `${verb(s)} ${s.name}`);
|
|
if (phrases.length === 1) {
|
|
return `Because you ${phrases[0]}.`;
|
|
}
|
|
if (phrases.length === 2) {
|
|
return `Because you ${phrases[0]} and ${phrases[1]}.`;
|
|
}
|
|
// 3 with Oxford comma
|
|
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';
|
|
// The 30 must not exceed the divisor below, or the singular "in about a
|
|
// month" is unreachable — a rounded month count of 1 needs 15..44 days,
|
|
// and any higher threshold sends all of those down the days branch. This
|
|
// read 45 and the singular case was dead code (caught by the Android
|
|
// unit test for the same logic).
|
|
if (days < 30) 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({
|
|
kind: 'artist',
|
|
lidarr_artist_mbid: s.mbid,
|
|
artist_name: s.name
|
|
});
|
|
optimisticRequested = withMbid(optimisticRequested, s.mbid, true);
|
|
// The server-side filter hides this candidate on next refetch.
|
|
await client.invalidateQueries({ queryKey: qk.suggestions() });
|
|
} catch {
|
|
// Swallow for v1; the SPA will refetch on next mount and the card
|
|
// 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>
|
|
<header class="mb-4 space-y-1">
|
|
<h2 class="font-display text-2xl font-medium text-text-primary">Suggested for you</h2>
|
|
<p class="text-text-secondary">Out-of-library artists drawn from what you've liked and played.</p>
|
|
</header>
|
|
|
|
{#if !query.isPending && suggestions.length === 0}
|
|
<!--
|
|
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)}
|
|
<DiscoverResultCard
|
|
kind="artist"
|
|
title={s.name}
|
|
imageUrl={s.image_url}
|
|
state={cardState(s)}
|
|
attribution={reasonText(s)}
|
|
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>
|