+
+
Notification emails
+
+ Nothing is emailed per event. Each person chooses which notifications reach their email in
+ their own settings.
+
+
+
+ {#if loadError}
+
+ Couldn't load the email schedule.
+
+
+ {:else if form === null}
+ Loading the email schedule…
+ {:else}
+
+
+
+
+
+
+
+
+
+ {/if}
+
diff --git a/web/src/lib/components/NotificationEmailCard.test.ts b/web/src/lib/components/NotificationEmailCard.test.ts
new file mode 100644
index 00000000..0d7f01d8
--- /dev/null
+++ b/web/src/lib/components/NotificationEmailCard.test.ts
@@ -0,0 +1,66 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
+
+vi.mock('#lib/api/admin.js', () => ({
+ getNotificationEmailSettings: vi.fn(),
+ updateNotificationEmailSettings: vi.fn()
+}));
+
+vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
+
+import NotificationEmailCard from './NotificationEmailCard.svelte';
+import { getNotificationEmailSettings, updateNotificationEmailSettings } from '#lib/api/admin.js';
+import { pushToast } from '#lib/stores/toast.svelte.js';
+
+afterEach(() => vi.clearAllMocks());
+
+async function renderCard() {
+ vi.mocked(getNotificationEmailSettings).mockResolvedValue({ summary_hour: 9, batch_window_minutes: 60 });
+ render(NotificationEmailCard);
+ return screen.findByRole('combobox', { name: /new music summary/i });
+}
+
+describe('NotificationEmailCard', () => {
+ test('shows the summary hour and the batch window', async () => {
+ const hour = await renderCard();
+ expect(hour).toHaveValue('9');
+ expect(screen.getByRole('option', { name: '09:00' })).toBeInTheDocument();
+ expect(screen.getByRole('spinbutton', { name: /batch everything else/i })).toHaveValue(60);
+ });
+
+ test('save stays off until something changes, then sends both values', async () => {
+ const hour = await renderCard();
+ const save = screen.getByRole('button', { name: 'Save' });
+ expect(save).toBeDisabled();
+
+ vi.mocked(updateNotificationEmailSettings).mockResolvedValue({ summary_hour: 7, batch_window_minutes: 60 });
+ await fireEvent.change(hour, { target: { value: '7' } });
+ expect(save).toBeEnabled();
+ await fireEvent.click(save);
+
+ await waitFor(() =>
+ expect(updateNotificationEmailSettings).toHaveBeenCalledWith({ summary_hour: 7, batch_window_minutes: 60 })
+ );
+ await waitFor(() => expect(save).toBeDisabled());
+ });
+
+ test("a refused save shows the server's reason", async () => {
+ await renderCard();
+ vi.mocked(updateNotificationEmailSettings).mockRejectedValue({
+ code: 'invalid_setting',
+ message: 'batch_window_minutes must be 15-1440',
+ status: 400
+ });
+ const batch = screen.getByRole('spinbutton', { name: /batch everything else/i });
+ await fireEvent.input(batch, { target: { value: '5' } });
+ await fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ await waitFor(() => expect(pushToast).toHaveBeenCalledWith(expect.stringContaining('15-1440'), 'error'));
+ });
+
+ test('a failed load offers a retry', async () => {
+ vi.mocked(getNotificationEmailSettings).mockRejectedValue(new Error('down'));
+ render(NotificationEmailCard);
+ expect(await screen.findByRole('button', { name: 'Try again' })).toBeInTheDocument();
+ });
+});
diff --git a/web/src/lib/components/NotificationSettings.svelte b/web/src/lib/components/NotificationSettings.svelte
new file mode 100644
index 00000000..c068d6fd
--- /dev/null
+++ b/web/src/lib/components/NotificationSettings.svelte
@@ -0,0 +1,132 @@
+
+
+{#snippet rows(list: NotificationKindSetting[])}
+