feat: Settings is one page per section down a left rail, Account first, every setting a row; the profile form no longer saves blanks over your name and email (M503 #5595 #5596 #5597)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m45s
release / go (push) Successful in 1m58s
release / integration (push) Successful in 5m39s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m45s
release / go (push) Successful in 1m58s
release / integration (push) Successful in 5m39s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
- /settings lands on Account: the Android app, profile, password, signed-in devices. Then Playback, Appearance, Notifications, Connections, About. - Each setting is a row (name, one short line, control at the end) in a capped-width column; one-of-N choices are a shared Segmented control. - Collection and Settings share one SectionRail. - Connections leads ListenBrainz with a status line: not connected, connected and not sending, or sending with the last scrobble's age. - Notifications prints its column labels once; the digest email's settings link points at /settings/notifications. - #5596: the profile form now loads what is stored and sends only the fields that changed. It used to start blank, and the server reads a blank as "clear", so one Save wiped the display name and email. - Recommendation metrics moves to Library › Your listening, and Hidden tracks gets a Library tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -119,12 +119,14 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Active sessions</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Every device signed in to your account. If you see one you don't recognise — especially
|
||||
one marked as having moved — sign it out and change your password.
|
||||
</p>
|
||||
<!-- Settings › Account's last group (#5595), styled as SettingsGroup is. -->
|
||||
<section class="space-y-3 rounded-lg border border-border bg-surface px-4 pt-3 pb-4">
|
||||
<header>
|
||||
<h2 class="text-base font-medium text-text-primary">Signed-in devices</h2>
|
||||
<p class="text-xs text-text-secondary">
|
||||
One you don't recognise? Sign it out and change your password.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if loadError}
|
||||
<p class="text-sm text-action-destructive">
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Smartphone } from 'lucide-svelte';
|
||||
import { Download, Smartphone } from 'lucide-svelte';
|
||||
import { api } from '#lib/api/client.js';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
|
||||
// Polls /api/client/version once on mount. Renders nothing on 404
|
||||
// (no bundled APK — graceful degradation in dev / pre-CI images)
|
||||
// or 401 (defensive — caller must be logged in).
|
||||
// The Android app this server bundles, at the top of Settings › Account
|
||||
// (#5595). Polls /api/client/version once on mount. Renders nothing on
|
||||
// 404 (no bundled APK: dev and pre-CI images) or 401.
|
||||
//
|
||||
// The /api/client/* endpoints are auth-gated to prevent anonymous
|
||||
// bandwidth abuse on the APK stream, so this component only shows
|
||||
// for logged-in users. Mount in Settings, never in pre-auth surfaces.
|
||||
// bandwidth abuse on the APK stream, so this only shows to a signed-in
|
||||
// user. Mount in Settings, never on a pre-auth page.
|
||||
|
||||
// Server returns snake_case (clientVersionResponse in
|
||||
// internal/api/client_assets.go). Map to a camelCase struct for
|
||||
// the rest of the component.
|
||||
// internal/api/client_assets.go).
|
||||
type WireInfo = { version: string; apk_url: string; size_bytes: number };
|
||||
type Info = { version: string; apkUrl: string; sizeBytes: number };
|
||||
|
||||
@@ -40,23 +41,19 @@
|
||||
</script>
|
||||
|
||||
{#if info}
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Mobile app</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Install the Android app paired to this server. Sign in with the same account.
|
||||
</p>
|
||||
<a
|
||||
href={info.apkUrl}
|
||||
download="minstrel.apk"
|
||||
class="mobile-app-download flex items-center gap-3 rounded-md border border-border bg-surface px-3 py-2 text-sm text-text-primary no-underline transition-colors hover:bg-surface-hover hover:text-accent-fg focus-visible:ring-2 focus-visible:ring-accent"
|
||||
<SettingsGroup title="Android app">
|
||||
<SettingRow
|
||||
label="Minstrel for Android"
|
||||
hint="{info.version}{info.sizeBytes > 0 ? ` · ${formatSize(info.sizeBytes)}` : ''} · sign in with this account"
|
||||
>
|
||||
<Smartphone size={18} strokeWidth={1.5} class="shrink-0 text-accent-fg" />
|
||||
<span class="flex flex-col leading-tight">
|
||||
<span class="font-medium">Get the Android app</span>
|
||||
<span class="text-xs text-text-secondary">
|
||||
{info.version}{info.sizeBytes > 0 ? ` · ${formatSize(info.sizeBytes)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</section>
|
||||
<Smartphone size={18} strokeWidth={1.5} class="text-text-secondary" aria-hidden="true" />
|
||||
<a
|
||||
href={info.apkUrl}
|
||||
download="minstrel.apk"
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg hover:opacity-90 focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Download size={16} strokeWidth={1.75} aria-hidden="true" /> Download
|
||||
</a>
|
||||
</SettingRow>
|
||||
</SettingsGroup>
|
||||
{/if}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
setNormalization
|
||||
} from '#lib/stores/normalization.svelte.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
import Segmented from '#lib/components/settings/Segmented.svelte';
|
||||
|
||||
// Volume leveling (M464 #4998). The choice is the user's, stored on the
|
||||
// server, so it follows them to the Android app and to casts.
|
||||
@@ -39,68 +41,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
const pill = (on: boolean) =>
|
||||
`flex cursor-pointer items-center rounded border border-border px-3 py-1.5 text-sm ${
|
||||
on ? 'bg-action-secondary text-action-fg' : 'text-text-primary hover:bg-surface-hover'
|
||||
}`;
|
||||
const modes = NORMALIZATION_MODES.map((m) => ({ value: m, label: modeLabels[m] }));
|
||||
const targets = NORMALIZATION_TARGETS.map((t) => ({ value: t, label: `${t} LUFS` }));
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="block text-sm text-text-secondary">Volume leveling</legend>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each NORMALIZATION_MODES as m (m)}
|
||||
<label class={pill(normalization.value.mode === m)}>
|
||||
<input
|
||||
type="radio"
|
||||
name="normalization-mode"
|
||||
value={m}
|
||||
checked={normalization.value.mode === m}
|
||||
onchange={() => change({ mode: m })}
|
||||
class="sr-only"
|
||||
/>
|
||||
{modeLabels[m]}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-text-secondary">{modeHints[normalization.value.mode]}</p>
|
||||
</fieldset>
|
||||
<!-- Rows of the Playback group (#5595); the caller wraps them in it. -->
|
||||
<SettingRow label="Volume leveling" hint={modeHints[normalization.value.mode]}>
|
||||
<Segmented
|
||||
name="normalization-mode"
|
||||
label="Volume leveling"
|
||||
options={modes}
|
||||
value={normalization.value.mode}
|
||||
onchange={(m) => change({ mode: m })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{#if normalization.value.mode !== 'off'}
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="block text-sm text-text-secondary">Target loudness</legend>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each NORMALIZATION_TARGETS as t (t)}
|
||||
<label class={pill(normalization.value.target_lufs === t)}>
|
||||
<input
|
||||
type="radio"
|
||||
name="normalization-target"
|
||||
value={t}
|
||||
checked={normalization.value.target_lufs === t}
|
||||
onchange={() => change({ target_lufs: t })}
|
||||
class="sr-only"
|
||||
/>
|
||||
<span class="tabular-nums">{t} LUFS</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-text-secondary">Higher is louder; −18 leaves the most headroom.</p>
|
||||
</fieldset>
|
||||
{#if normalization.value.mode !== 'off'}
|
||||
<SettingRow label="Target loudness" hint="Higher is louder; −18 leaves the most headroom.">
|
||||
<Segmented
|
||||
name="normalization-target"
|
||||
label="Target loudness"
|
||||
options={targets}
|
||||
value={normalization.value.target_lufs}
|
||||
onchange={(t) => change({ target_lufs: t })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<label class="flex cursor-pointer items-center justify-between gap-3">
|
||||
<span>
|
||||
<span class="block text-sm text-text-primary">Boost quiet tracks fully</span>
|
||||
<span class="block text-xs text-text-secondary">A limiter catches the peaks.</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={normalization.value.boost === 'limiter'}
|
||||
checked={normalization.value.boost === 'limiter'}
|
||||
onchange={(e) =>
|
||||
change({ boost: (e.currentTarget as HTMLInputElement).checked ? 'limiter' : 'headroom' })}
|
||||
class="h-4 w-4 accent-accent"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
<SettingRow label="Boost quiet tracks fully" hint="A limiter catches the peaks." for="normalization-boost">
|
||||
<input
|
||||
id="normalization-boost"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={normalization.value.boost === 'limiter'}
|
||||
checked={normalization.value.boost === 'limiter'}
|
||||
onchange={(e) =>
|
||||
change({ boost: (e.currentTarget as HTMLInputElement).checked ? 'limiter' : 'headroom' })}
|
||||
class="h-4 w-4 cursor-pointer accent-accent"
|
||||
/>
|
||||
</SettingRow>
|
||||
{/if}
|
||||
|
||||
@@ -80,53 +80,62 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet rows(list: NotificationKindSetting[])}
|
||||
<div class="grid grid-cols-[1fr_repeat(3,3.5rem)] items-center gap-y-1 text-sm">
|
||||
<span></span>
|
||||
{#each channels as c (c.key)}
|
||||
<span class="text-center text-xs text-text-secondary">{c.label}</span>
|
||||
{/each}
|
||||
{#each list as row (row.kind)}
|
||||
<span class="py-1 text-text-primary">{label(row.kind)}</span>
|
||||
{#each channels as c (c.key)}
|
||||
<span class="flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="{label(row.kind)}: {c.label}"
|
||||
checked={row[c.key] && !disabled(row, c.key)}
|
||||
disabled={disabled(row, c.key)}
|
||||
onchange={() => toggle(row, c.key)}
|
||||
/>
|
||||
</span>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{#snippet row(r: NotificationKindSetting)}
|
||||
<span class="py-1.5 text-text-primary">{label(r.kind)}</span>
|
||||
{#each channels as c (c.key)}
|
||||
<span class="flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="{label(r.kind)}: {c.label}"
|
||||
checked={r[c.key] && !disabled(r, c.key)}
|
||||
disabled={disabled(r, c.key)}
|
||||
onchange={() => toggle(r, c.key)}
|
||||
/>
|
||||
</span>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<section id="notifications" class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Notifications</h2>
|
||||
<!-- Settings › Notifications (#5595): one table, its column labels once
|
||||
(#189), the admins' library-health kinds under their own heading in
|
||||
the same columns. -->
|
||||
<section id="notifications" class="rounded-lg border border-border bg-surface">
|
||||
<header class="px-4 pt-3 pb-2">
|
||||
<h2 class="text-base font-medium text-text-primary">Notifications</h2>
|
||||
<p class="text-xs text-text-secondary">What reaches you, and where. Phone and email follow the inbox.</p>
|
||||
</header>
|
||||
|
||||
{#if !settings}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
{:else}
|
||||
{@render rows(forEveryone)}
|
||||
|
||||
{#if forAdmins.length > 0}
|
||||
<h3 class="pt-2 text-sm font-medium text-text-secondary">Library health</h3>
|
||||
{@render rows(forAdmins)}
|
||||
{/if}
|
||||
|
||||
{#if !settings.email_available}
|
||||
<p class="text-sm text-text-secondary" data-testid="email-unavailable">
|
||||
{#if settings.email_unavailable_reason === 'no_address'}
|
||||
Email is off: <a href="#profile" class="underline hover:text-text-primary">add an email address in your profile</a>.
|
||||
{:else if user.value?.is_admin}
|
||||
Email is off: <a href="/admin/integrations" class="underline hover:text-text-primary">SMTP isn't set up</a>.
|
||||
{:else}
|
||||
Email is off: this server doesn't send email.
|
||||
<div class="border-t border-border px-4 py-3">
|
||||
{#if !settings}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-[1fr_repeat(3,3.5rem)] items-center gap-y-1 text-sm">
|
||||
<span></span>
|
||||
{#each channels as c (c.key)}
|
||||
<span class="text-center text-xs text-text-secondary">{c.label}</span>
|
||||
{/each}
|
||||
{#each forEveryone as r (r.kind)}
|
||||
{@render row(r)}
|
||||
{/each}
|
||||
{#if forAdmins.length > 0}
|
||||
<h3 class="col-span-4 pt-3 text-xs font-medium uppercase tracking-wide text-text-secondary">Library health</h3>
|
||||
{#each forAdmins as r (r.kind)}
|
||||
{@render row(r)}
|
||||
{/each}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !settings.email_available}
|
||||
<p class="mt-3 text-xs text-text-secondary" data-testid="email-unavailable">
|
||||
{#if settings.email_unavailable_reason === 'no_address'}
|
||||
Email is off: <a href="/settings/account#profile" class="underline hover:text-text-primary">add an email address in your profile</a>.
|
||||
{:else if user.value?.is_admin}
|
||||
Email is off: <a href="/admin/integrations" class="underline hover:text-text-primary">SMTP isn't set up</a>.
|
||||
{:else}
|
||||
Email is off: this server doesn't send email.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('NotificationSettings', () => {
|
||||
expect(screen.getByRole('checkbox', { name: 'Request approved: Email' })).toBeDisabled();
|
||||
expect(screen.getByRole('link', { name: 'add an email address in your profile' })).toHaveAttribute(
|
||||
'href',
|
||||
'#profile'
|
||||
'/settings/account#profile'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<script lang="ts">
|
||||
import type { CreateQueryResult } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createRecommendationMetricsQuery,
|
||||
type RecommendationMetrics,
|
||||
type SurfaceIntent,
|
||||
type MetricDelta
|
||||
} from '#lib/api/metrics.js';
|
||||
|
||||
// How plays launched from each recommendation surface land against the
|
||||
// music you pick yourself. Library › Your listening (#5597): a report on
|
||||
// your own listening, so it moved out of Settings, which holds choices.
|
||||
|
||||
const metrics = createRecommendationMetricsQuery() as CreateQueryResult<RecommendationMetrics>;
|
||||
|
||||
// Per-intent expectation copy: each surface band is judged against its
|
||||
// job — discovery mixes are supposed to run hotter skip rates.
|
||||
const intentHints: Record<SurfaceIntent, string> = {
|
||||
go_to: 'Everyday surfaces — these should beat your own picking.',
|
||||
discovery: 'Exploration surfaces — higher skip rates here are expected and healthy.',
|
||||
direct: 'Music you chose yourself — naturally close to the baseline.'
|
||||
};
|
||||
|
||||
function pct(v: number): string {
|
||||
return `${(v * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
// Deltas come from the server with their margin of error (#2495). The client
|
||||
// no longer subtracts rates itself: the margin needs the sample sizes and
|
||||
// variances, and having both clients re-derive it invites them to disagree.
|
||||
//
|
||||
// A delta that isn't distinguishable from zero is prefixed "≈" and dimmed.
|
||||
// That is the point of this whole change — the card used to render a −12 on
|
||||
// 59 plays exactly as boldly as a −6 on 400, and the first of those is noise.
|
||||
function deltaText(d: MetricDelta | undefined): string {
|
||||
if (!d) return '';
|
||||
const pts = Math.round(d.delta_pp);
|
||||
const signed = pts > 0 ? `+${pts}` : `${pts}`;
|
||||
return d.distinguishable ? signed : `≈${signed}`;
|
||||
}
|
||||
|
||||
function deltaTitle(d: MetricDelta | undefined): string | undefined {
|
||||
if (!d) return undefined;
|
||||
const range = `${d.delta_pp.toFixed(1)} ± ${d.margin_pp.toFixed(1)} points vs baseline`;
|
||||
return d.distinguishable
|
||||
? `${range} (95% confidence)`
|
||||
: `${range} — not distinguishable from zero at 95% confidence, so read this as no measured difference.`;
|
||||
}
|
||||
|
||||
// A skip delta is "worse" above the baseline; a completion delta is "worse"
|
||||
// below it. Neither gets a colour unless it's distinguishable — colouring
|
||||
// noise red is what made the old card misleading.
|
||||
function skipDeltaClass(d: MetricDelta | undefined): string {
|
||||
if (!d?.distinguishable) return 'text-text-secondary opacity-60';
|
||||
return d.delta_pp > 0 ? 'text-danger' : 'text-text-secondary';
|
||||
}
|
||||
|
||||
function completionDeltaClass(d: MetricDelta | undefined): string {
|
||||
if (!d?.distinguishable) return 'text-text-secondary opacity-60';
|
||||
return d.delta_pp < 0 ? 'text-danger' : 'text-text-secondary';
|
||||
}
|
||||
|
||||
// Pick-kind breakdowns are collapsed by default (#1270): with every
|
||||
// system mix stamping provenance, always-open sub-rows would triple
|
||||
// the table height and bury the surface-level comparison.
|
||||
let expandedBreakdowns = $state(new Set<string>());
|
||||
|
||||
function toggleBreakdown(key: string) {
|
||||
const next = new Set(expandedBreakdowns);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
expandedBreakdowns = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Recommendation metrics</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
How plays launched from each recommendation surface land, over the last
|
||||
{$metrics.data?.window_days ?? 30} days — compared against the baseline of
|
||||
music you picked yourself. Deltas are percentage points vs that baseline.
|
||||
</p>
|
||||
{#if $metrics.isPending}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
{:else if $metrics.isError}
|
||||
<p class="text-sm text-text-secondary">Couldn't load metrics.</p>
|
||||
{:else if $metrics.data && ($metrics.data.groups.length > 0 || $metrics.data.baseline)}
|
||||
{@const baseline = $metrics.data.baseline}
|
||||
{#if baseline}
|
||||
<p class="rounded bg-background px-3 py-2 text-sm">
|
||||
<span class="font-medium">Baseline — manual plays:</span>
|
||||
<span class="text-text-secondary">
|
||||
{baseline.plays} plays · {pct(baseline.skip_rate)} skip ·
|
||||
{pct(baseline.avg_completion)} completion
|
||||
</span>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-text-secondary">
|
||||
No manual plays in this window yet, so deltas are hidden — raw rates only.
|
||||
</p>
|
||||
{/if}
|
||||
{#each $metrics.data.groups as group (group.intent)}
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-medium">{group.label}</h3>
|
||||
<p class="text-xs text-text-secondary">{intentHints[group.intent]}</p>
|
||||
<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 text-right font-medium">Plays</th>
|
||||
<th class="py-1 text-right font-medium">Skip rate</th>
|
||||
<th class="py-1 text-right font-medium">Avg completion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each group.surfaces as m (m.key)}
|
||||
<tr
|
||||
class="border-t border-border"
|
||||
class:opacity-60={m.low_confidence}
|
||||
title={m.low_confidence
|
||||
? 'Fewer than 20 plays — treat these rates as anecdote, not signal.'
|
||||
: undefined}
|
||||
>
|
||||
<td class="py-1">
|
||||
{#if (m.breakdown ?? []).length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded outline-hidden focus-visible:ring-1 focus-visible:ring-accent"
|
||||
aria-expanded={expandedBreakdowns.has(m.key)}
|
||||
onclick={() => toggleBreakdown(m.key)}
|
||||
>
|
||||
<span class="text-xs text-text-secondary" aria-hidden="true">
|
||||
{expandedBreakdowns.has(m.key) ? '▾' : '▸'}
|
||||
</span>
|
||||
{m.label}
|
||||
</button>
|
||||
{:else}
|
||||
{m.label}
|
||||
{/if}{#if m.low_confidence}<span class="ml-1 text-xs text-text-secondary">· low data</span>{/if}
|
||||
</td>
|
||||
<td class="py-1 text-right tabular-nums">{m.plays}</td>
|
||||
<td class="py-1 text-right tabular-nums">
|
||||
{pct(m.skip_rate)}
|
||||
{#if m.skip_delta}
|
||||
<span class="ml-1 text-xs {skipDeltaClass(m.skip_delta)}"
|
||||
data-testid="skip-delta-{m.key}"
|
||||
title={deltaTitle(m.skip_delta)}>{deltaText(m.skip_delta)}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-1 text-right tabular-nums">
|
||||
{pct(m.avg_completion)}
|
||||
{#if m.completion_delta}
|
||||
<span class="ml-1 text-xs {completionDeltaClass(m.completion_delta)}"
|
||||
data-testid="completion-delta-{m.key}"
|
||||
title={deltaTitle(m.completion_delta)}>{deltaText(m.completion_delta)}</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Pick-kind provenance sub-rows (#1249/#1270): each
|
||||
surface splits into the populations its builder
|
||||
stamped — For You's taste vs freshness injection,
|
||||
Discover's buckets, the tiered mixes' tiers — the
|
||||
numbers that say WHERE a surface's skips come from.
|
||||
Toggled per surface to keep the default view flat. -->
|
||||
{#if expandedBreakdowns.has(m.key)}
|
||||
{#each m.breakdown ?? [] as b (b.key)}
|
||||
<tr
|
||||
class="border-t border-border/50 text-text-secondary"
|
||||
class:opacity-60={b.low_confidence}
|
||||
title={b.low_confidence
|
||||
? 'Fewer than 20 plays — treat these rates as anecdote, not signal.'
|
||||
: undefined}
|
||||
>
|
||||
<td class="py-1 pl-4 text-xs">
|
||||
↳ {b.label}{#if b.low_confidence}<span class="ml-1">· low data</span>{/if}
|
||||
</td>
|
||||
<td class="py-1 text-right text-xs tabular-nums">{b.plays}</td>
|
||||
<td class="py-1 text-right text-xs tabular-nums">
|
||||
{pct(b.skip_rate)}
|
||||
{#if b.skip_delta}
|
||||
<span class="ml-1 {skipDeltaClass(b.skip_delta)}"
|
||||
data-testid="skip-delta-{b.key}"
|
||||
title={deltaTitle(b.skip_delta)}>{deltaText(b.skip_delta)}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-1 text-right text-xs tabular-nums">
|
||||
{pct(b.avg_completion)}
|
||||
{#if b.completion_delta}
|
||||
<span class="ml-1 {completionDeltaClass(b.completion_delta)}"
|
||||
data-testid="completion-delta-{b.key}"
|
||||
title={deltaTitle(b.completion_delta)}>{deltaText(b.completion_delta)}</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
<p class="text-xs text-text-secondary">
|
||||
Deltas compare each surface with your manual plays. A delta marked
|
||||
<span class="opacity-60">≈</span> is smaller than its own margin of error at this
|
||||
sample size — it can't be told apart from no difference, however big it looks.
|
||||
Hover any delta for its range.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-text-secondary">
|
||||
No plays recorded yet. Play something from For You, Discover, or a mix.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
// Moved with the card from Settings to Library › Your listening (#5597).
|
||||
|
||||
// Mutable holder so individual tests can inject populated metrics;
|
||||
// vi.mock is hoisted, hence vi.hoisted for the shared reference.
|
||||
const metricsMock = vi.hoisted(() => ({
|
||||
data: { window_days: 30, baseline: null, groups: [] } as unknown
|
||||
}));
|
||||
|
||||
vi.mock('#lib/api/metrics.js', () => ({
|
||||
createRecommendationMetricsQuery: () => ({
|
||||
subscribe: (run: (v: unknown) => void) => {
|
||||
run({ isPending: false, isError: false, data: metricsMock.data });
|
||||
return () => {};
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
import RecommendationMetrics from './RecommendationMetrics.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
metricsMock.data = { window_days: 30, baseline: null, groups: [] };
|
||||
});
|
||||
|
||||
describe('RecommendationMetrics (Library › Your listening)', () => {
|
||||
const metric = (key: string, label: string, over: Record<string, unknown> = {}) => ({
|
||||
key,
|
||||
label,
|
||||
plays: 30,
|
||||
skips: 3,
|
||||
skip_rate: 0.1,
|
||||
avg_completion: 0.9,
|
||||
low_confidence: false,
|
||||
...over
|
||||
});
|
||||
|
||||
test('breakdown rows are collapsed by default and expand on toggle (#1249/#1270)', async () => {
|
||||
metricsMock.data = {
|
||||
window_days: 30,
|
||||
baseline: metric('manual', 'Manual library plays', { plays: 100 }),
|
||||
groups: [
|
||||
{
|
||||
intent: 'go_to',
|
||||
label: 'Go-to surfaces',
|
||||
surfaces: [
|
||||
metric('for_you', 'For You', {
|
||||
plays: 45,
|
||||
breakdown: [
|
||||
metric('for_you_taste', 'Taste picks'),
|
||||
metric('for_you_fresh', 'Fresh picks', {
|
||||
plays: 10,
|
||||
skip_rate: 0.4,
|
||||
low_confidence: true
|
||||
}),
|
||||
metric('for_you_unattributed', 'Earlier plays', { plays: 5 })
|
||||
]
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
render(RecommendationMetrics);
|
||||
const toggle = await screen.findByRole('button', { name: /for you/i });
|
||||
// Collapsed by default: every stamping mix now carries a breakdown,
|
||||
// so always-open sub-rows would swamp the surface-level view.
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText(/Taste picks/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Fresh picks/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Earlier plays/)).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(toggle);
|
||||
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// #2495: the card used to render a delta computed client-side with no notion
|
||||
// of uncertainty, so a -12 on 59 plays looked exactly as solid as a -6 on 400.
|
||||
// Deltas now arrive from the server with a margin, and an indistinguishable
|
||||
// one is marked with "≈" and dimmed rather than coloured.
|
||||
test('a delta smaller than its margin is marked as indistinguishable', async () => {
|
||||
metricsMock.data = {
|
||||
window_days: 30,
|
||||
baseline: metric('manual', 'Manual library plays', { plays: 400, skip_rate: 0.27 }),
|
||||
groups: [
|
||||
{
|
||||
intent: 'discovery',
|
||||
label: 'Discovery mixes',
|
||||
surfaces: [
|
||||
metric('discover', 'Discover', {
|
||||
plays: 59,
|
||||
skip_rate: 0.153,
|
||||
// 13.3pp gap, but the margin at n=59 is wider than the gap.
|
||||
skip_delta: { delta_pp: -13.3, margin_pp: 14.5, distinguishable: false },
|
||||
completion_delta: { delta_pp: 28.0, margin_pp: 12.1, distinguishable: true }
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
render(RecommendationMetrics);
|
||||
await waitFor(() => expect(screen.getByText('Discover')).toBeInTheDocument());
|
||||
|
||||
// Targeted by test id rather than text: the legend below the table also
|
||||
// contains a "≈", so matching on the glyph finds the explanation instead of
|
||||
// the delta. (It did, on the first attempt at this test.)
|
||||
const skip = screen.getByTestId('skip-delta-discover');
|
||||
expect(skip).toHaveTextContent('≈-13');
|
||||
expect(skip).toHaveAttribute('title', expect.stringContaining('not distinguishable from zero'));
|
||||
// It must NOT be coloured as a real regression/improvement.
|
||||
expect(skip.className).toContain('opacity-60');
|
||||
|
||||
// The completion delta clears its margin, so it renders plainly.
|
||||
const completion = screen.getByTestId('completion-delta-discover');
|
||||
expect(completion).toHaveTextContent('+28');
|
||||
expect(completion.className).not.toContain('opacity-60');
|
||||
|
||||
// And the legend explains the glyph rather than leaving it a mystery.
|
||||
expect(screen.getByText(/smaller than its own margin of error/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A delta is omitted entirely when the samples are too thin for a margin to
|
||||
// mean anything — the server decides that, and the cell must simply show the
|
||||
// rate rather than a bare "0".
|
||||
test('a surface with no delta shows its rate and nothing else', async () => {
|
||||
metricsMock.data = {
|
||||
window_days: 30,
|
||||
baseline: metric('manual', 'Manual library plays', { plays: 400 }),
|
||||
groups: [
|
||||
{
|
||||
intent: 'go_to',
|
||||
label: 'Go-to surfaces',
|
||||
surfaces: [metric('radio', 'Radio', { plays: 1, skip_rate: 0 })]
|
||||
}
|
||||
]
|
||||
};
|
||||
render(RecommendationMetrics);
|
||||
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
|
||||
expect(screen.queryByTestId('skip-delta-radio')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('completion-delta-radio')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('surfaces without a breakdown render no toggle and no sub-rows', async () => {
|
||||
metricsMock.data = {
|
||||
window_days: 30,
|
||||
baseline: null,
|
||||
groups: [
|
||||
{
|
||||
intent: 'go_to',
|
||||
label: 'Go-to surfaces',
|
||||
surfaces: [metric('radio', 'Radio')]
|
||||
}
|
||||
]
|
||||
};
|
||||
render(RecommendationMetrics);
|
||||
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
|
||||
expect(screen.queryByRole('button', { name: /radio/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" module>
|
||||
export type RailItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
attention?: number;
|
||||
// Further paths that belong to this item, e.g. an artist's page under
|
||||
// the Catalogue. The item is also current on any path below its href.
|
||||
also?: string[];
|
||||
// The item is current only on its exact href (a section's landing page,
|
||||
// whose href prefixes every other item's).
|
||||
exact?: boolean;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
|
||||
// A section's pages, down the left (#5572, #5595): Collection and Settings
|
||||
// both use it, so a section's navigation sits in the same place and looks
|
||||
// the same everywhere (#189, "stable chrome"). On a narrow screen the rail
|
||||
// runs across the top. An item can carry a count and, accented, how many
|
||||
// of those need a person.
|
||||
|
||||
let { label, items }: { label: string; items: RailItem[] } = $props();
|
||||
|
||||
function isActive(item: RailItem): boolean {
|
||||
const path = page.url.pathname;
|
||||
if (item.also?.some((p) => path.startsWith(p))) return true;
|
||||
return item.exact ? path === item.href : path === item.href || path.startsWith(item.href + '/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav aria-label={label} class="border-b border-border md:border-b-0 md:border-r md:pr-3">
|
||||
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
|
||||
{#each items as item (item.href)}
|
||||
{@const active = isActive(item)}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm transition-colors md:rounded-md md:border-b-0 md:border-l-2 {active
|
||||
? 'border-accent text-text-primary md:bg-surface-hover'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary md:hover:bg-surface-hover'}"
|
||||
>
|
||||
<span class="md:flex-1">{item.label}</span>
|
||||
{#if item.attention}
|
||||
<span
|
||||
class="rounded-full bg-accent px-1.5 text-xs text-text-on-accent"
|
||||
aria-label="{item.attention} need you"
|
||||
data-testid="tab-attention">{item.attention}</span
|
||||
>
|
||||
{/if}
|
||||
{#if item.count !== undefined}
|
||||
<span class="text-xs tabular-nums text-text-muted">{item.count}</span>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -42,8 +42,11 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- The value of Settings › About's "Server version" row (#5595). -->
|
||||
{#if version}
|
||||
<p class="text-xs text-text-secondary">
|
||||
Server {version}{#if channel} · {channel}{/if}
|
||||
</p>
|
||||
<span class="text-sm tabular-nums text-text-secondary" data-testid="server-version">
|
||||
{version}{#if channel} · {channel}{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-sm text-text-muted">—</span>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" generics="V extends string | number">
|
||||
// A one-of-N choice as a row of joined buttons (#5595): Theme, Volume
|
||||
// leveling, Target loudness. Each option is a real radio, so it is
|
||||
// reachable by keyboard and named by its own text.
|
||||
|
||||
let {
|
||||
name,
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onchange
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
options: { value: V; label: string }[];
|
||||
value: V;
|
||||
onchange: (v: V) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div role="radiogroup" aria-label={label} class="inline-flex overflow-hidden rounded-md border border-border">
|
||||
{#each options as o, i (o.value)}
|
||||
<label
|
||||
class="flex cursor-pointer items-center px-3 py-1.5 text-sm transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent has-[:focus-visible]:ring-inset
|
||||
{i > 0 ? 'border-l border-border' : ''}
|
||||
{value === o.value ? 'bg-action-secondary text-action-fg' : 'text-text-primary hover:bg-surface-hover'}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
{name}
|
||||
value={o.value}
|
||||
checked={value === o.value}
|
||||
onchange={() => onchange(o.value)}
|
||||
class="sr-only"
|
||||
/>
|
||||
<span class="whitespace-nowrap tabular-nums">{o.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// One setting (#188, #5595): its name and at most one short line on the
|
||||
// left, its control at the end. `for` names the control's id so the name
|
||||
// labels it. `hint` is a line of text, or a snippet when it needs a link.
|
||||
// A control too wide to sit beside the name (a form field on a phone)
|
||||
// wraps beneath it.
|
||||
|
||||
let {
|
||||
label,
|
||||
hint,
|
||||
for: forId,
|
||||
children
|
||||
}: { label: string; hint?: string | Snippet; for?: string; children?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if forId}
|
||||
<label for={forId} class="block text-sm text-text-primary">{label}</label>
|
||||
{:else}
|
||||
<span class="block text-sm text-text-primary">{label}</span>
|
||||
{/if}
|
||||
{#if typeof hint === 'function'}
|
||||
<p class="text-xs text-text-secondary">{@render hint()}</p>
|
||||
{:else if hint}
|
||||
<p class="text-xs text-text-secondary">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if children}
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// A group of settings on a Settings page (#5595): a titled card whose
|
||||
// rows are SettingRows, ruled apart. A settings page reads as a menu
|
||||
// (#188), so a group carries a title and, at most, one short line.
|
||||
|
||||
let {
|
||||
title,
|
||||
hint,
|
||||
id,
|
||||
children
|
||||
}: { title: string; hint?: string; id?: string; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<section {id} class="rounded-lg border border-border bg-surface">
|
||||
<header class="px-4 pt-3 pb-2">
|
||||
<h2 class="text-base font-medium text-text-primary">{title}</h2>
|
||||
{#if hint}
|
||||
<p class="text-xs text-text-secondary">{hint}</p>
|
||||
{/if}
|
||||
</header>
|
||||
<div class="divide-y divide-border border-t border-border">
|
||||
{@render children()}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,20 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import SectionRail from '#lib/components/SectionRail.svelte';
|
||||
import type { WantCounts } from '#lib/api/types.js';
|
||||
|
||||
// The Collection's sections, down the left like Lidarr's (#5572): the
|
||||
// catalogue of artists first, then the sections that show state. On a
|
||||
// narrow screen the rail runs across the top. Activity carries a second,
|
||||
// accented count when downloads are waiting on a person. Unmatched, like
|
||||
// Lidarr's Unmapped Files, holds the library albums no MusicBrainz album
|
||||
// matched (#5579).
|
||||
// catalogue of artists first, then the sections that show state. Activity
|
||||
// carries a second, accented count when downloads are waiting on a person.
|
||||
// Unmatched, like Lidarr's Unmapped Files, holds the library albums no
|
||||
// MusicBrainz album matched (#5579).
|
||||
|
||||
let { counts }: { counts?: WantCounts } = $props();
|
||||
|
||||
type Item = { href: string; label: string; count?: number; attention?: number };
|
||||
|
||||
const items = $derived<Item[]>([
|
||||
{ href: '/collection', label: 'Catalogue', count: counts?.artists },
|
||||
// An artist's page belongs to the catalogue.
|
||||
const items = $derived([
|
||||
{ href: '/collection', label: 'Catalogue', count: counts?.artists, exact: true, also: ['/collection/artist/'] },
|
||||
{ href: '/collection/wanted', label: 'Wanted', count: counts?.wanted },
|
||||
{ href: '/collection/missing', label: 'Missing files', count: counts?.missing },
|
||||
{ href: '/collection/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
|
||||
@@ -22,40 +20,6 @@
|
||||
{ href: '/collection/blocked', label: 'Blocked', count: counts?.blocked },
|
||||
{ href: '/collection/unmatched', label: 'Unmatched', count: counts?.unmatched }
|
||||
]);
|
||||
|
||||
// An artist's page belongs to the catalogue.
|
||||
function isActive(href: string): boolean {
|
||||
const path = page.url.pathname;
|
||||
if (href === '/collection') return path === '/collection' || path.startsWith('/collection/artist/');
|
||||
return path.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav aria-label="Collection sections" class="border-b border-border md:border-b-0 md:border-r md:pr-3">
|
||||
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
|
||||
{#each items as item (item.href)}
|
||||
{@const active = isActive(item.href)}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm transition-colors md:rounded-md md:border-b-0 md:border-l-2 {active
|
||||
? 'border-accent text-text-primary md:bg-surface-hover'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary md:hover:bg-surface-hover'}"
|
||||
>
|
||||
<span class="md:flex-1">{item.label}</span>
|
||||
{#if item.attention}
|
||||
<span
|
||||
class="rounded-full bg-accent px-1.5 text-xs text-text-on-accent"
|
||||
aria-label="{item.attention} need you"
|
||||
data-testid="tab-attention">{item.attention}</span
|
||||
>
|
||||
{/if}
|
||||
{#if item.count !== undefined}
|
||||
<span class="text-xs tabular-nums text-text-muted">{item.count}</span>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
<SectionRail label="Collection sections" {items} />
|
||||
|
||||
Reference in New Issue
Block a user