Rewrites the web app's comments to rule 32. Task, milestone, rule and spec ids are gone; regression stories are restated as the trap they guard (public auth routes, the menu's stopPropagation, universal loads under adapter-static, metric deltas with their margin). Flutter and Tauri mentions now name the Android app or a native shell, operator quotes and dates are dropped, and one library's counts no longer appear outside test fixtures. Verified with a TypeScript-scanner token comparison (template literals and regex literals re-scanned): only comment text changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
94 lines
3.3 KiB
Svelte
94 lines
3.3 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import {
|
|
getNotificationEmailSettings,
|
|
updateNotificationEmailSettings,
|
|
type NotificationEmailSettings
|
|
} from '#lib/api/admin.js';
|
|
import { errMessage } from '#lib/api/errors.js';
|
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
|
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
|
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
|
|
|
// When notification emails go out. Nothing is emailed per
|
|
// event: new music is a daily summary, everything else a batch.
|
|
|
|
const button =
|
|
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
|
const field = 'w-24 rounded-md border border-border bg-background px-2 py-1.5 text-sm';
|
|
|
|
let saved = $state<NotificationEmailSettings | null>(null);
|
|
let form = $state<NotificationEmailSettings | null>(null);
|
|
let saving = $state(false);
|
|
let loadError = $state(false);
|
|
|
|
const dirty = $derived(
|
|
!!saved &&
|
|
!!form &&
|
|
(saved.summary_hour !== form.summary_hour ||
|
|
saved.batch_window_minutes !== form.batch_window_minutes)
|
|
);
|
|
|
|
const hours = Array.from({ length: 24 }, (_, h) => h);
|
|
|
|
function hourLabel(h: number): string {
|
|
return `${String(h).padStart(2, '0')}:00`;
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
saved = await getNotificationEmailSettings();
|
|
form = { ...saved };
|
|
loadError = false;
|
|
} catch {
|
|
loadError = true;
|
|
}
|
|
}
|
|
|
|
onMount(load);
|
|
|
|
async function save() {
|
|
if (!form) return;
|
|
saving = true;
|
|
try {
|
|
saved = await updateNotificationEmailSettings(form);
|
|
form = { ...saved };
|
|
pushToast('Notification email schedule saved.');
|
|
} catch (e) {
|
|
// The server names the field it refused (errMessage, not e.message:
|
|
// the client throws a plain object).
|
|
pushToast(errMessage(e), 'error');
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<SettingsGroup title="Notification emails" id="notification-emails" hint="Never one per event. Each person picks theirs in their own settings.">
|
|
{#if loadError}
|
|
<p class="px-4 py-3 text-sm text-error-fg">
|
|
Couldn't load the email schedule.
|
|
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
|
|
</p>
|
|
{:else if form === null}
|
|
<p class="px-4 py-3 text-sm text-text-secondary">Loading the email schedule…</p>
|
|
{:else}
|
|
<SettingRow label="New music summary" for="summary-hour" hint="Daily, at this hour in each person's timezone.">
|
|
<select id="summary-hour" bind:value={form.summary_hour} class={field}>
|
|
{#each hours as h (h)}
|
|
<option value={h}>{hourLabel(h)}</option>
|
|
{/each}
|
|
</select>
|
|
</SettingRow>
|
|
<SettingRow label="Batch everything else" for="batch-window" hint="One email this long after the first item.">
|
|
<input id="batch-window" type="number" min="15" max="1440" bind:value={form.batch_window_minutes} class={field} />
|
|
<span class="text-sm text-text-secondary">minutes</span>
|
|
</SettingRow>
|
|
<div class="flex justify-end px-4 py-3">
|
|
<button type="button" class={button} disabled={!dirty || saving} onclick={save}>
|
|
{saving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</SettingsGroup>
|