feat(tuning): weekly trend view — per-surface series + knob-turn markers
test-go / test (push) Successful in 33s
test-web / test (push) Failing after 38s
test-go / integration (push) Successful in 4m44s

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:
2026-07-03 09:29:33 -04:00
parent 0d0a8f46b1
commit 9ad4343c76
9 changed files with 773 additions and 15 deletions
+37
View File
@@ -47,3 +47,40 @@ export function patchTuning(
export function resetTuning(scope: TuningScope): Promise<TuningSnapshot> {
return api.post<TuningSnapshot>(`/api/admin/recommendation-tuning/${scope}/reset`, {});
}
// Trends (#1251): weekly per-surface outcome series with knob-turn
// markers. Mirrors internal/api/admin_recommendation_trends.go.
export type TrendPoint = {
week_start: string;
plays: number;
skips: number;
skip_rate: number;
avg_completion: number;
taste_hit_rate: number;
};
export type TrendSeries = {
key: string;
label: string;
intent: string;
plays: number;
points: TrendPoint[];
};
export type TrendMarker = {
changed_at: string;
scope: string;
action: string;
changes: { field: string; old: number; new: number }[];
};
export type TrendsResponse = {
weeks: number;
series: TrendSeries[];
markers: TrendMarker[];
};
export function getTrends(weeks = 12): Promise<TrendsResponse> {
return api.get<TrendsResponse>(`/api/admin/recommendation-trends?weeks=${weeks}`);
}
+185 -1
View File
@@ -4,10 +4,14 @@
getTuning,
patchTuning,
resetTuning,
getTrends,
type TuningScope,
type TuningSnapshot,
type WeightProfile,
type TasteTuning
type TasteTuning,
type TrendsResponse,
type TrendSeries,
type TrendMarker
} from '$lib/api/tuning';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
@@ -124,6 +128,86 @@
saving = null;
}
}
// Trends (#1251): weekly skip-rate sparklines per surface with
// knob-turn markers — the verify half of the tune→verify loop.
let trends = $state<TrendsResponse | null>(null);
let trendsFailed = $state(false);
$effect(() => {
getTrends()
.then((t) => {
trends = t;
})
.catch(() => {
trendsFailed = true;
});
});
const SPARK_W = 220;
const SPARK_H = 36;
const TREND_LOW_VOLUME = 20;
// The week axis is the sorted union of every series' buckets, so all
// sparklines and markers share one x scale.
const weekAxis = $derived.by(() => {
if (!trends) return [] as string[];
const set = new Set<string>();
for (const s of trends.series) for (const p of s.points) set.add(p.week_start);
return [...set].sort();
});
function xFor(week: string): number {
const i = weekAxis.indexOf(week);
if (i < 0 || weekAxis.length < 2) return 0;
return (i / (weekAxis.length - 1)) * SPARK_W;
}
// Polyline of the series' weekly skip rate on a fixed [0,1] y-scale
// (higher = worse, drawn upward) so rows are visually comparable.
function sparkPoints(s: TrendSeries): string {
return s.points
.map((p) => `${xFor(p.week_start).toFixed(1)},${(SPARK_H - p.skip_rate * SPARK_H).toFixed(1)}`)
.join(' ');
}
// A marker lands on the latest axis week that starts at/before it.
function markerX(m: TrendMarker): number {
const day = m.changed_at.slice(0, 10);
let idx = -1;
for (let i = 0; i < weekAxis.length; i++) {
if (weekAxis[i] <= day) idx = i;
}
if (idx < 0 || weekAxis.length < 2) return 0;
return (idx / (weekAxis.length - 1)) * SPARK_W;
}
function windowTasteHitRate(s: TrendSeries): number {
let plays = 0;
let hits = 0;
for (const p of s.points) {
plays += p.plays;
hits += p.taste_hit_rate * p.plays;
}
return plays > 0 ? hits / plays : 0;
}
function latest(s: TrendSeries): { skip: number; completion: number } {
const last = s.points[s.points.length - 1];
return last ? { skip: last.skip_rate, completion: last.avg_completion } : { skip: 0, completion: 0 };
}
function pct(v: number): string {
return `${(v * 100).toFixed(0)}%`;
}
function markerSummary(m: TrendMarker): string {
const when = m.changed_at.slice(0, 10);
if (m.action === 'reset') return `${when}${m.scope} reset to defaults`;
const fields = (m.changes ?? []).map((c) => `${c.field} ${c.old}${c.new}`).join(', ');
return `${when}${m.scope}: ${fields}`;
}
</script>
<svelte:head>
@@ -247,4 +331,104 @@
</div>
</section>
{/if}
<!-- Weekly trends (#1251): the verify half. Sparklines share one
week axis; dashed ticks mark knob turns so cause→effect reads
off the chart. -->
<section class="space-y-3 rounded border border-border bg-surface p-4">
<div>
<h2 class="text-lg font-semibold">Weekly trends</h2>
<p class="text-xs text-text-secondary">
Skip rate per surface over the last {trends?.weeks ?? 12} weeks (lower is better; all
users aggregated, rates only). Dashed ticks mark tuning changes. Taste hit is the share
of plays whose artist fits the current taste profile.
</p>
</div>
{#if trendsFailed}
<p class="text-sm text-text-secondary">Couldn't load trends.</p>
{:else if !trends}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if trends.series.length === 0}
<p class="text-sm text-text-secondary">
No plays recorded yet — trends appear once listening accumulates.
</p>
{:else}
<table class="w-full text-sm">
<thead>
<tr class="text-left text-text-secondary">
<th class="py-1 font-medium">Surface</th>
<th class="py-1 font-medium">Skip rate by week</th>
<th class="py-1 text-right font-medium">Plays</th>
<th class="py-1 text-right font-medium">Latest skip</th>
<th class="py-1 text-right font-medium">Latest completion</th>
<th class="py-1 text-right font-medium">Taste hit</th>
</tr>
</thead>
<tbody>
{#each trends.series as s (s.key)}
<tr
class="border-t border-border"
class:opacity-60={s.plays < TREND_LOW_VOLUME}
title={s.plays < TREND_LOW_VOLUME
? 'Fewer than 20 plays in the window — treat as anecdote, not signal.'
: undefined}
>
<td class="py-1.5 pr-2">{s.label}</td>
<td class="py-1.5">
<svg
width={SPARK_W}
height={SPARK_H}
viewBox="0 0 {SPARK_W} {SPARK_H}"
role="img"
aria-label="{s.label} weekly skip rate"
data-testid="sparkline-{s.key}"
>
<line x1="0" y1={SPARK_H - 0.5} x2={SPARK_W} y2={SPARK_H - 0.5}
stroke="currentColor" opacity="0.15" />
{#each trends.markers as m, i (i)}
<line
x1={markerX(m)} y1="0" x2={markerX(m)} y2={SPARK_H}
stroke="currentColor" opacity="0.35" stroke-dasharray="2,2"
>
<title>{markerSummary(m)}</title>
</line>
{/each}
{#if s.points.length > 1}
<polyline
class="text-accent"
points={sparkPoints(s)}
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
{:else if s.points.length === 1}
<circle
class="text-accent"
cx={xFor(s.points[0].week_start)}
cy={SPARK_H - s.points[0].skip_rate * SPARK_H}
r="2" fill="currentColor"
/>
{/if}
</svg>
</td>
<td class="py-1.5 text-right tabular-nums">{s.plays}</td>
<td class="py-1.5 text-right tabular-nums">{pct(latest(s).skip)}</td>
<td class="py-1.5 text-right tabular-nums">{pct(latest(s).completion)}</td>
<td class="py-1.5 text-right tabular-nums">{pct(windowTasteHitRate(s))}</td>
</tr>
{/each}
</tbody>
</table>
{#if trends.markers.length > 0}
<div class="space-y-1">
<h3 class="text-sm font-medium">Tuning changes in this window</h3>
<ul class="space-y-0.5 text-xs text-text-secondary">
{#each trends.markers as m, i (i)}
<li>{markerSummary(m)}</li>
{/each}
</ul>
</div>
{/if}
{/if}
</section>
</div>
+83 -3
View File
@@ -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()
);
});
});