feat(tuning): weekly trend view — per-surface series + knob-turn markers
The verify half of the tune→verify loop (#1251), on the same admin Tuning page as the knobs: - RecommendationWeeklyTrends: weekly per-source outcomes aggregated across all users (the knobs are global, so judging a turn needs global outcomes — rows carry rates only, no track/user identity), with a taste-hit count per bucket: plays whose track's artist has a positive weight in the player's current taste profile. That's the "cheap recompute" reading — retroactive over the whole window, at the cost of profile drift. - GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52): per-family weekly series (skip rate, sample-weighted completion, taste-hit rate) plus the tuning-audit markers inside the window. - Web: sparkline table under the tuning cards — skip rate per week on a shared axis with dashed ticks at knob turns, latest-week columns, window taste-hit rate, low-volume rows dimmed as anecdote, and a plain-text list of the window's tuning changes. Also fixes the revive unused-parameter lint on the tuning GET handler that failed CI run 1903 on the previous commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import type { TuningSnapshot } from '$lib/api/tuning';
|
||||
import type { TuningSnapshot, TrendsResponse } from '$lib/api/tuning';
|
||||
|
||||
vi.mock('$lib/api/tuning', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
@@ -8,7 +8,8 @@ vi.mock('$lib/api/tuning', async (orig) => {
|
||||
...actual,
|
||||
getTuning: vi.fn(),
|
||||
patchTuning: vi.fn(),
|
||||
resetTuning: vi.fn()
|
||||
resetTuning: vi.fn(),
|
||||
getTrends: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
@@ -16,7 +17,7 @@ const pushToast = vi.fn();
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) }));
|
||||
|
||||
import TuningPage from './+page.svelte';
|
||||
import { getTuning, patchTuning, resetTuning } from '$lib/api/tuning';
|
||||
import { getTuning, patchTuning, resetTuning, getTrends } from '$lib/api/tuning';
|
||||
|
||||
const weights = (over: Partial<Record<string, number>> = {}) => ({
|
||||
base_weight: 1,
|
||||
@@ -50,8 +51,11 @@ function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
|
||||
} as TuningSnapshot;
|
||||
}
|
||||
|
||||
const emptyTrends: TrendsResponse = { weeks: 12, series: [], markers: [] };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(getTrends as ReturnType<typeof vi.fn>).mockResolvedValue(emptyTrends);
|
||||
});
|
||||
|
||||
describe('Admin tuning page', () => {
|
||||
@@ -111,4 +115,80 @@ describe('Admin tuning page', () => {
|
||||
screen.getByTitle(/deviates from the shipped default \(1\.5\)/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders weekly trend rows with sparklines and knob-turn markers (#1251)', async () => {
|
||||
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
(getTrends as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
weeks: 12,
|
||||
series: [
|
||||
{
|
||||
key: 'radio',
|
||||
label: 'Radio',
|
||||
intent: 'go_to',
|
||||
plays: 40,
|
||||
points: [
|
||||
{
|
||||
week_start: '2026-06-22',
|
||||
plays: 25,
|
||||
skips: 5,
|
||||
skip_rate: 0.2,
|
||||
avg_completion: 0.8,
|
||||
taste_hit_rate: 0.6
|
||||
},
|
||||
{
|
||||
week_start: '2026-06-29',
|
||||
plays: 15,
|
||||
skips: 6,
|
||||
skip_rate: 0.4,
|
||||
avg_completion: 0.7,
|
||||
taste_hit_rate: 0.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'discover',
|
||||
label: 'Discover',
|
||||
intent: 'discovery',
|
||||
plays: 5,
|
||||
points: [
|
||||
{
|
||||
week_start: '2026-06-29',
|
||||
plays: 5,
|
||||
skips: 3,
|
||||
skip_rate: 0.6,
|
||||
avg_completion: 0.4,
|
||||
taste_hit_rate: 0.2
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
markers: [
|
||||
{
|
||||
changed_at: '2026-06-30T10:00:00Z',
|
||||
scope: 'radio',
|
||||
action: 'update',
|
||||
changes: [{ field: 'taste_weight', old: 1, new: 2 }]
|
||||
}
|
||||
]
|
||||
} satisfies TrendsResponse);
|
||||
render(TuningPage);
|
||||
await waitFor(() => expect(screen.getByText('Weekly trends')).toBeInTheDocument());
|
||||
expect(screen.getByTestId('sparkline-radio')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sparkline-discover')).toBeInTheDocument();
|
||||
// Latest skip rate column for radio = 40% (also discover's latest
|
||||
// completion, hence getAllBy).
|
||||
expect(screen.getAllByText('40%').length).toBeGreaterThan(0);
|
||||
// The knob turn is listed under the chart.
|
||||
expect(screen.getByText(/2026-06-30 — radio: taste_weight 1→2/)).toBeInTheDocument();
|
||||
// Low-volume series (5 plays) is dimmed as anecdote.
|
||||
expect(screen.getByTitle(/fewer than 20 plays in the window/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('empty trends show the accumulation hint', async () => {
|
||||
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
render(TuningPage);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/trends appear once listening accumulates/i)).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user