feat(discover): Discover tuning card on the admin lab — #2377 (web admin)
test-web / test (push) Successful in 35s

Rule #25/#27: the two knobs slice 6 added server-side are now touchable —
taste-tag weight and snooze length, with deviation dots, save, and reset,
matching the existing profile/taste cards.

Copy states what each knob does AND what it doesn't: the tag-weight hint
says 0 turns the term off and that an untagged candidate is never
penalised, and the snooze hint says it records no opinion about the artist
and never feeds the taste profile. Those are the two properties most likely
to be assumed backwards by whoever turns these next.

Also fixed a latent fragility the new card exposed rather than caused: all
three reset buttons had the accessible name "Reset to defaults", so the
existing test picked the LAST one and assumed that meant taste. Adding a
card below it would have silently retargeted that assertion at the wrong
scope. Each reset button now names its scope — better for screen readers
too, since three identical buttons on one page is a real a11y defect — and
the test selects by name instead of position.

The page's test fixture needed the new `discover` key in both `snapshot`
and `shipped`: the `as TuningSnapshot` cast means a missing field is not a
compile error, it's every test on the page throwing inside fillForm. Noted
that in the fixture so the next scope doesn't rediscover it.

Includes a test that a weight of 0 is actually SENT rather than dropped as
falsy — the off switch is the one value a truthiness bug would eat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 23:49:17 -04:00
co-authored by Claude Opus 5
parent cf0d37bf8e
commit ca4832e620
3 changed files with 167 additions and 6 deletions
+11 -1
View File
@@ -26,14 +26,24 @@ export type TasteTuning = {
mood_scale: number;
};
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
// Discover request-surface knobs (#2377). Its own scope rather than part of
// taste: snooze_days belongs here, and a snooze must never be read as taste
// signal (#2374).
export type DiscoverTuning = {
tag_overlap_weight: number;
snooze_days: number;
};
export type TuningScope = 'radio' | 'daily_mix' | 'taste' | 'discover';
export type TuningSnapshot = {
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
taste: TasteTuning;
discover: DiscoverTuning;
shipped: {
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
taste: TasteTuning;
discover: DiscoverTuning;
};
};
+79 -2
View File
@@ -9,6 +9,7 @@
type TuningSnapshot,
type WeightProfile,
type TasteTuning,
type DiscoverTuning,
type TrendsResponse,
type TrendSeries,
type TrendMarker
@@ -43,6 +44,11 @@
{ key: 'mood_scale', label: 'Mood weight', hint: 'How strongly a mood-tagged play imprints on the mood facet (from folksonomy tags), in [0, 1]. 0 = mood ignored.' }
];
const discoverFields: { key: keyof DiscoverTuning; label: string; hint: string }[] = [
{ key: 'tag_overlap_weight', label: 'Taste-tag weight', hint: "How strongly a candidate's tags matching your taste boosts it. score x (1 + w x overlap), so 0 turns the tag term off and ranks on similarity alone. An artist with no cached tags is never penalised." },
{ key: 'snooze_days', label: 'Snooze length (days)', hint: 'How long "not right now" parks a suggestion before it returns on its own. Records no opinion about the artist and never feeds the taste profile.' }
];
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
{ scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' },
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, Songs like…, and the discovery mixes.' }
@@ -55,11 +61,17 @@
let saving = $state<TuningScope | null>(null);
function fillForm(snap: TuningSnapshot) {
const f: Record<string, Record<string, string>> = { radio: {}, daily_mix: {}, taste: {} };
const f: Record<string, Record<string, string>> = {
radio: {},
daily_mix: {},
taste: {},
discover: {}
};
for (const p of ['radio', 'daily_mix'] as const) {
for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]);
}
for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]);
for (const { key } of discoverFields) f.discover[key] = String(snap.discover[key]);
form = f;
}
@@ -76,11 +88,17 @@
// A knob deviates when its CURRENT SAVED value differs from shipped;
// the dot marks where this install has drifted from defaults.
function deviates(scope: 'radio' | 'daily_mix' | 'taste', key: string): boolean {
function deviates(scope: TuningScope, key: string): boolean {
if (!snapshot) return false;
if (scope === 'taste') {
return snapshot.taste[key as keyof TasteTuning] !== snapshot.shipped.taste[key as keyof TasteTuning];
}
if (scope === 'discover') {
return (
snapshot.discover[key as keyof DiscoverTuning] !==
snapshot.shipped.discover[key as keyof DiscoverTuning]
);
}
return (
snapshot.profiles[scope][key as keyof WeightProfile] !==
snapshot.shipped.profiles[scope][key as keyof WeightProfile]
@@ -90,6 +108,7 @@
function currentValue(scope: TuningScope, key: string): number {
if (!snapshot) return 0;
if (scope === 'taste') return snapshot.taste[key as keyof TasteTuning];
if (scope === 'discover') return snapshot.discover[key as keyof DiscoverTuning];
return snapshot.profiles[scope][key as keyof WeightProfile];
}
@@ -276,6 +295,7 @@
type="button"
disabled={saving !== null}
onclick={() => reset(p.scope)}
aria-label="Reset {p.label} to defaults"
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
Reset to defaults
@@ -328,6 +348,63 @@
type="button"
disabled={saving !== null}
onclick={() => reset('taste')}
aria-label="Reset taste profile build to defaults"
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
Reset to defaults
</button>
</div>
</section>
<!-- Discover request surface (#2377). Its own scope, not part of taste:
the snooze length lives here, and a snooze deliberately carries no
taste signal (#2374). -->
<section class="space-y-3 rounded border border-border bg-surface p-4 lg:max-w-xl">
<div>
<h2 class="text-lg font-semibold">Discover requests</h2>
<p class="text-xs text-text-secondary">
How the Discover suggestion deck ranks out-of-library artists. Tag coverage for
artists you don't own is partial by nature, so an untagged candidate keeps its
similarity score rather than being pushed down.
</p>
</div>
<div class="space-y-2">
{#each discoverFields as f (f.key)}
<div class="grid grid-cols-[1fr_7rem] items-center gap-2">
<label class="text-sm" for="discover-{f.key}" title={f.hint}>
{f.label}
{#if deviates('discover', f.key)}
<span
class="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle"
title="Deviates from the shipped default ({snapshot.shipped.discover[f.key]})"
></span>
{/if}
</label>
<input
id="discover-{f.key}"
type="number"
step={f.key === 'snooze_days' ? '1' : '0.1'}
min="0"
bind:value={form.discover[f.key]}
class="w-full rounded border border-border bg-background px-2 py-1 text-right text-sm tabular-nums outline-none focus:border-accent"
/>
</div>
{/each}
</div>
<div class="flex gap-2">
<button
type="button"
disabled={saving !== null}
onclick={() => save('discover')}
class="rounded bg-accent px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
Save discover
</button>
<button
type="button"
disabled={saving !== null}
onclick={() => reset('discover')}
aria-label="Reset discover requests to defaults"
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
Reset to defaults
+77 -3
View File
@@ -43,13 +43,25 @@ const taste = (over: Partial<Record<string, number>> = {}) => ({
...over
});
const discover = (over: Partial<Record<string, number>> = {}) => ({
tag_overlap_weight: 1,
snooze_days: 90,
...over
});
// NOTE the `as TuningSnapshot` cast below: it silences the type error when the
// server snapshot grows a field, so a missing key here surfaces as every test
// on this page throwing inside fillForm rather than as a compile failure. Add
// new scopes to BOTH this fixture and `shipped`.
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
return {
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste(),
discover: discover(),
shipped: {
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste()
taste: taste(),
discover: discover()
},
...over
} as TuningSnapshot;
@@ -99,13 +111,17 @@ describe('Admin tuning page', () => {
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing changed.'));
});
// Targets the button by its accessible name, not its position. This used to
// click the LAST reset button and assume that meant taste — which silently
// became the wrong scope the moment a card was added below it.
test('reset calls resetTuning for the scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Taste profile build')).toBeInTheDocument());
const resetButtons = screen.getAllByRole('button', { name: /reset to defaults/i });
await fireEvent.click(resetButtons[resetButtons.length - 1]);
await fireEvent.click(
screen.getByRole('button', { name: /reset taste profile build to defaults/i })
);
await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('taste'));
});
@@ -198,4 +214,62 @@ describe('Admin tuning page', () => {
expect(screen.getByText(/trends appear once listening accumulates/i)).toBeInTheDocument()
);
});
// --- Discover scope (#2377) ---
test('renders the Discover card with its two knobs', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
expect(screen.getByLabelText(/taste-tag weight/i)).toBeInTheDocument();
expect(screen.getByLabelText(/snooze length/i)).toBeInTheDocument();
});
test('saving Discover patches only the changed field under the discover scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
await fireEvent.input(screen.getByLabelText(/taste-tag weight/i), {
target: { value: '2.5' }
});
await fireEvent.click(screen.getByRole('button', { name: /save discover/i }));
await waitFor(() =>
expect(patchTuning).toHaveBeenCalledWith('discover', { tag_overlap_weight: 2.5 })
);
});
// 0 is the operator's off switch for the whole tag term, so the form must be
// able to send it — a falsy-value bug here would silently make the feature
// impossible to disable.
test('a Discover weight of 0 is sent, not dropped as falsy', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
await fireEvent.input(screen.getByLabelText(/taste-tag weight/i), { target: { value: '0' } });
await fireEvent.click(screen.getByRole('button', { name: /save discover/i }));
await waitFor(() =>
expect(patchTuning).toHaveBeenCalledWith('discover', { tag_overlap_weight: 0 })
);
});
test('reset targets the discover scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
await fireEvent.click(
screen.getByRole('button', { name: /reset discover requests to defaults/i })
);
await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('discover'));
});
test('a deviating Discover knob is dotted', async () => {
const snap = snapshot();
snap.discover = discover({ tag_overlap_weight: 3 });
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snap);
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
expect(screen.getByTitle(/deviates from the shipped default \(1\)/i)).toBeInTheDocument();
});
});