Notifications inbox (M489), Discover taste-arm fix, web tooling majors #151

Merged
bvandeusen merged 26 commits from dev into main 2026-10-08 11:11:09 -04:00
7 changed files with 412 additions and 1 deletions
Showing only changes of commit 956058d4a0 - Show all commits
+73
View File
@@ -0,0 +1,73 @@
import { createQuery } from '@tanstack/svelte-query';
import { api } from './client';
import { qk } from './queries';
// The notifications inbox (M489). The server renders title, body and link per
// kind, so every client says the same thing and this module never has to know
// what a kind means.
export type NotificationItem = {
id: string;
kind: string;
title: string;
body: string;
link: string;
created_at: string;
read_at: string | null;
};
export type NotificationPage = {
items: NotificationItem[];
unread_count: number;
next_before?: string;
};
// The bell polls as a fallback for a dropped live-event stream; the
// `notification.created` event is what makes it prompt. A minute is slow
// enough to cost nothing and quick enough that a missed nudge is not missed
// for long. Polling pauses while the tab is hidden (TanStack's default).
export const NOTIFICATIONS_POLL_MS = 60_000;
export const NOTIFICATIONS_PAGE_SIZE = 30;
export async function listNotifications(before?: string): Promise<NotificationPage> {
const params = new URLSearchParams({ limit: String(NOTIFICATIONS_PAGE_SIZE) });
if (before) params.set('before', before);
return api.get<NotificationPage>(`/api/me/notifications?${params}`);
}
export async function unreadNotificationCount(): Promise<number> {
const res = await api.get<{ unread_count: number }>('/api/me/notifications/unread-count');
return res.unread_count;
}
export async function markNotificationRead(id: string): Promise<void> {
await api.post(`/api/me/notifications/${encodeURIComponent(id)}/read`, {});
}
export async function markAllNotificationsRead(): Promise<void> {
await api.post('/api/me/notifications/read-all', {});
}
export function createNotificationsQuery() {
return createQuery({
queryKey: qk.notifications(),
queryFn: () => listNotifications(),
staleTime: 30_000,
refetchInterval: NOTIFICATIONS_POLL_MS
});
}
export function createUnreadCountQuery() {
return createQuery({
queryKey: qk.notificationsUnread(),
queryFn: unreadNotificationCount,
staleTime: 30_000,
refetchInterval: NOTIFICATIONS_POLL_MS
});
}
/** The badge's text: nothing at zero, the count up to nine, then "9+". */
export function badgeLabel(count: number): string {
if (count <= 0) return '';
return count > 9 ? '9+' : String(count);
}
+5
View File
@@ -61,6 +61,11 @@ export const qk = {
adminDiagnosticDevices: (userId?: string) =>
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
smtpConfig: () => ['smtpConfig'] as const,
// Notifications inbox (M489). Both keys sit under ['notifications'] so one
// prefix invalidation refreshes the list and the badge together.
notifications: () => ['notifications', 'list'] as const,
notificationsUnread: () => ['notifications', 'unread'] as const,
notificationSettings: () => ['notificationSettings'] as const,
suggestions: (limit?: number) =>
['suggestions', { limit: limit ?? 12 }] as const,
suggestionSnoozes: () => ['suggestionSnoozes'] as const,
@@ -0,0 +1,148 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { useQueryClient } from '@tanstack/svelte-query';
import { Bell } from 'lucide-svelte';
import {
badgeLabel,
createNotificationsQuery,
createUnreadCountQuery,
markAllNotificationsRead,
markNotificationRead,
type NotificationItem
} from '$lib/api/notifications';
import { relativeTime } from '$lib/utils/relativeTime';
import { pushToast } from '$lib/stores/toast.svelte';
// The notifications inbox (M489): a bell in the header with an unread badge,
// opening a panel of what happened while you were away.
const client = useQueryClient();
const unreadStore = createUnreadCountQuery();
const listStore = createNotificationsQuery();
const unread = $derived(($unreadStore.data as number | undefined) ?? 0);
const items = $derived((($listStore.data?.items ?? []) as NotificationItem[]));
const badge = $derived(badgeLabel(unread));
let open = $state(false);
let panelRef: HTMLElement | undefined = $state();
let buttonRef: HTMLElement | undefined = $state();
// Same outside-click shape as the user menu in Shell: no stopPropagation on
// the panel, so SvelteKit's link interception keeps working inside it.
function handleWindowClick(e: MouseEvent) {
if (!open) return;
const target = e.target as Node | null;
if (!target) return;
if (panelRef?.contains(target) || buttonRef?.contains(target)) return;
open = false;
}
function refresh() {
client.invalidateQueries({ queryKey: ['notifications'] });
}
async function openItem(item: NotificationItem) {
open = false;
if (!item.read_at) {
try {
await markNotificationRead(item.id);
} catch {
// Opening the subject matters more than the read mark; the next
// refresh shows it unread again, which is the honest state.
}
refresh();
}
if (item.link) await goto(item.link);
}
async function markAll() {
try {
await markAllNotificationsRead();
refresh();
} catch {
pushToast("Couldn't mark them read. Try again in a moment.", 'error');
}
}
</script>
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (open = false)} />
<div class="relative flex-shrink-0">
<button
bind:this={buttonRef}
type="button"
class="relative flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
aria-label={unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'}
aria-haspopup="dialog"
aria-expanded={open}
onclick={() => (open = !open)}
>
<Bell size={20} strokeWidth={1.75} aria-hidden="true" />
{#if badge}
<span
data-testid="notification-badge"
class="absolute right-1 top-1 min-w-[18px] rounded-full bg-text-primary px-1 text-center text-xs leading-[18px] text-background"
aria-hidden="true"
>
{badge}
</span>
{/if}
</button>
{#if open}
<div
bind:this={panelRef}
class="absolute right-0 z-50 mt-1 flex max-h-[70vh] w-[min(22rem,calc(100vw-2rem))] flex-col rounded border border-border bg-surface shadow"
role="dialog"
aria-label="Notifications"
>
<div class="flex items-center justify-between border-b border-border px-3 py-2">
<span class="text-sm font-medium text-text-primary">Notifications</span>
{#if unread > 0}
<button
type="button"
class="rounded px-2 py-1 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
onclick={markAll}
>
Mark all read
</button>
{/if}
</div>
{#if items.length === 0}
<p class="px-3 py-6 text-center text-sm text-text-secondary">Nothing waiting for you.</p>
{:else}
<ul class="overflow-y-auto">
{#each items as item (item.id)}
<li class="border-b border-border last:border-b-0">
<button
type="button"
data-testid="notification-item"
data-unread={item.read_at ? undefined : 'true'}
class="flex w-full gap-2 px-3 py-2 text-left hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
onclick={() => openItem(item)}
>
<span
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full {item.read_at ? '' : 'bg-text-primary'}"
aria-hidden="true"
></span>
<span class="min-w-0 flex-1">
<span
class="block text-sm {item.read_at ? 'text-text-secondary' : 'font-medium text-text-primary'}"
>
{item.title}
{#if !item.read_at}<span class="sr-only"> (unread)</span>{/if}
</span>
{#if item.body}
<span class="block text-sm text-text-secondary">{item.body}</span>
{/if}
<span class="block text-xs text-text-muted">{relativeTime(item.created_at)}</span>
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
const invalidateMock = vi.fn();
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) };
});
vi.mock('$lib/api/notifications', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
badgeLabel: actual.badgeLabel,
createUnreadCountQuery: vi.fn(),
createNotificationsQuery: vi.fn(),
markNotificationRead: vi.fn().mockResolvedValue(undefined),
markAllNotificationsRead: vi.fn().mockResolvedValue(undefined)
};
});
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
import NotificationBell from './NotificationBell.svelte';
import { goto } from '$app/navigation';
import {
badgeLabel,
createNotificationsQuery,
createUnreadCountQuery,
markAllNotificationsRead,
markNotificationRead,
type NotificationItem
} from '$lib/api/notifications';
function item(over: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 'n1',
kind: 'request_completed',
title: 'Arrived: Boards of Canada – Geogaddi',
body: 'Your request is in the library.',
link: '/albums/al-1',
created_at: new Date().toISOString(),
read_at: null,
...over
};
}
function setInbox(items: NotificationItem[], unread = items.filter((i) => !i.read_at).length) {
(createUnreadCountQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: unread }));
(createNotificationsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: { items, unread_count: unread } })
);
}
async function openPanel() {
await fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('badgeLabel', () => {
test('is empty at zero, the count to nine, then 9+', () => {
expect(badgeLabel(0)).toBe('');
expect(badgeLabel(1)).toBe('1');
expect(badgeLabel(9)).toBe('9');
expect(badgeLabel(10)).toBe('9+');
expect(badgeLabel(250)).toBe('9+');
});
});
describe('NotificationBell', () => {
test('shows the unread count on the badge and in the label', () => {
setInbox([item(), item({ id: 'n2' }), item({ id: 'n3' })]);
render(NotificationBell);
expect(screen.getByTestId('notification-badge')).toHaveTextContent('3');
expect(screen.getByRole('button', { name: 'Notifications, 3 unread' })).toBeInTheDocument();
});
test('caps the badge at 9+', () => {
setInbox([item()], 42);
render(NotificationBell);
expect(screen.getByTestId('notification-badge')).toHaveTextContent('9+');
});
test('no badge when everything is read', () => {
setInbox([item({ read_at: new Date().toISOString() })]);
render(NotificationBell);
expect(screen.queryByTestId('notification-badge')).toBeNull();
expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument();
});
test('lists each kind with the server-rendered title and body, unread marked', async () => {
setInbox([
item({ id: 'a', kind: 'request_approved', title: 'Request approved', body: 'Geogaddi is on its way.' }),
item({
id: 'b',
kind: 'tracks_missing',
title: '4 tracks went missing',
body: 'Their files are no longer where the library expects them.',
link: '/admin/missing-files',
read_at: new Date().toISOString()
})
]);
render(NotificationBell);
await openPanel();
const rows = screen.getAllByTestId('notification-item');
expect(rows).toHaveLength(2);
expect(rows[0]).toHaveTextContent('Request approved');
expect(rows[0]).toHaveTextContent('Geogaddi is on its way.');
expect(rows[0]).toHaveAttribute('data-unread', 'true');
expect(rows[1]).toHaveTextContent('4 tracks went missing');
expect(rows[1]).not.toHaveAttribute('data-unread');
});
test('clicking an unread item marks it read and opens its subject', async () => {
setInbox([item({ id: 'n7', link: '/albums/al-7' })]);
render(NotificationBell);
await openPanel();
await fireEvent.click(screen.getByTestId('notification-item'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/albums/al-7'));
expect(markNotificationRead).toHaveBeenCalledWith('n7');
expect(invalidateMock).toHaveBeenCalledWith({ queryKey: ['notifications'] });
expect(screen.queryByRole('dialog', { name: 'Notifications' })).toBeNull();
});
test('clicking a read item only opens it', async () => {
setInbox([item({ read_at: new Date().toISOString(), link: '/requests' })]);
render(NotificationBell);
await openPanel();
await fireEvent.click(screen.getByTestId('notification-item'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/requests'));
expect(markNotificationRead).not.toHaveBeenCalled();
});
test('mark all read clears everything and refreshes', async () => {
setInbox([item(), item({ id: 'n2' })]);
render(NotificationBell);
await openPanel();
await fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }));
await waitFor(() => expect(markAllNotificationsRead).toHaveBeenCalled());
expect(invalidateMock).toHaveBeenCalledWith({ queryKey: ['notifications'] });
});
test('an empty inbox says so and offers no mark all', async () => {
setInbox([]);
render(NotificationBell);
await openPanel();
expect(screen.getByText('Nothing waiting for you.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Mark all read' })).toBeNull();
});
});
+4 -1
View File
@@ -6,6 +6,7 @@
import { player } from '$lib/player/store.svelte';
import { appName } from '$lib/branding';
import MinstrelMark from './MinstrelMark.svelte';
import NotificationBell from './NotificationBell.svelte';
import PlayerBar from './PlayerBar.svelte';
import SearchInput from './SearchInput.svelte';
@@ -92,11 +93,13 @@
{/each}
</nav>
<div class="flex items-center gap-3 md:gap-6 justify-self-end min-w-0">
<div class="flex items-center gap-1 md:gap-3 justify-self-end min-w-0">
<div class="hidden md:block min-w-0 max-w-xs flex-shrink">
<SearchInput />
</div>
<NotificationBell />
<div class="relative flex-shrink-0">
<button
bind:this={menuButtonRef}
+17
View File
@@ -24,6 +24,23 @@ vi.mock('$lib/auth/store.svelte', () => ({
logout: vi.fn().mockResolvedValue(undefined)
}));
// The bell's queries need a QueryClient the Shell tests do not provide; its own
// behaviour is covered in NotificationBell.test.ts.
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
});
vi.mock('$lib/api/notifications', async () => {
const { mockQuery } = await import('../../test-utils/query');
return {
createUnreadCountQuery: () => mockQuery({ data: 0 }),
createNotificationsQuery: () => mockQuery({ data: { items: [], unread_count: 0 } }),
markNotificationRead: vi.fn(),
markAllNotificationsRead: vi.fn(),
badgeLabel: () => ''
};
});
import Shell from './Shell.svelte';
import { logout } from '$lib/auth/store.svelte';
import { goto } from '$app/navigation';
+8
View File
@@ -32,6 +32,12 @@ function onSystemRebuilt(): void {
queryClient.invalidateQueries({ queryKey: qk.systemPlaylistsStatus() });
}
// A new notification for this user (M489). The frame carries no content — it
// is a nudge — so the inbox and badge refetch rather than trusting a payload.
function onNotificationCreated(): void {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
}
// Wire once from +layout.svelte's mount. Opens the stream only while
// authenticated and closes it on logout; EventSource auto-reconnects on
// transient network drops.
@@ -42,8 +48,10 @@ export function useServerEvents(): void {
if (typeof EventSource === 'undefined') return;
const es = new EventSource('/api/events/stream');
es.addEventListener('playlist.system_rebuilt', onSystemRebuilt);
es.addEventListener('notification.created', onNotificationCreated);
return () => {
es.removeEventListener('playlist.system_rebuilt', onSystemRebuilt);
es.removeEventListener('notification.created', onNotificationCreated);
es.close();
};
});