'use client' import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' import { useRouter } from 'next/navigation' import AdminShell from '../../ui/AdminShell' import { authFetch, getApiBase } from '../../lib/auth' import '../../email-recaps/recaps.css' type Settings = { enabled: boolean; day: number; hour: number; public_url: string; next_send_at?: number | null } type Delivery = { id: string; month: string; kind: string; email: string; username: string | null; state: string; attempts: number; created_at: number; updated_at: number; next_attempt_at: number; detail: string } type Overview = { settings: Settings; ready: boolean; detail: string; months: string[]; deliveries: Delivery[]; total: number; subscribers: number; worker_enabled: boolean } type Preview = { month: string; subject: string; body_html: string; body_text: string; email: string | null } const monthLabel = (month: string) => new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: 'long', year: 'numeric', timeZone: 'UTC' }) const dateLabel = (value?: number | null) => value ? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC` : 'Not scheduled' const stateLabels: Record = { queued: 'Queued', preparing: 'Preparing report', sending: 'Sending', sent: 'Accepted by mail server', retry: 'Retry scheduled', failed: 'Failed', unknown: 'Needs review', cancelled: 'Cancelled' } export default function EmailRecapsAdminPage() { const router = useRouter() const [data, setData] = useState(null) const [settings, setSettings] = useState({ enabled: false, day: 2, hour: 9, public_url: '' }) const [month, setMonth] = useState('') const [preview, setPreview] = useState(null) const [previewMode, setPreviewMode] = useState<'html' | 'text'>('html') const [error, setError] = useState('') const [notice, setNotice] = useState('') const [busy, setBusy] = useState('') const [offset, setOffset] = useState(0) const [revision, setRevision] = useState(0) const testRequest = useRef<{ month: string; id: string } | null>(null) const initialized = useRef(false) const previewController = useRef(null) const responseData = useCallback(async (response: Response) => { if (response.status === 401) { router.replace('/login?next=%2Fadmin%2Frecaps'); throw new Error('Sign in to continue.') } if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') } const result = await response.json().catch(() => ({})) if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not complete this action. Check your settings and try again.') return result }, [router]) useEffect(() => { const abort = new AbortController() void authFetch(`${getApiBase()}/admin/email-recaps?offset=${offset}`, { signal: abort.signal }).then(responseData).then((result: Overview) => { if (abort.signal.aborted) return setData(result) if (!initialized.current) { setSettings(result.settings); setMonth(result.months[0] || ''); initialized.current = true } }).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) }) return () => abort.abort() }, [offset, revision, responseData]) useEffect(() => { if (!data?.deliveries.some((delivery) => ['queued', 'preparing', 'sending', 'retry'].includes(delivery.state))) return const timer = window.setInterval(() => setRevision((value) => value + 1), 10000) return () => window.clearInterval(timer) }, [data]) useEffect(() => () => previewController.current?.abort(), []) const dirty = !!data && (settings.enabled !== data.settings.enabled || settings.day !== data.settings.day || settings.hour !== data.settings.hour || settings.public_url !== data.settings.public_url) const save = async (event: FormEvent) => { event.preventDefault() if (busy) return setBusy('save'); setError(''); setNotice('') try { const { enabled, day, hour, public_url } = settings const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, day, hour, public_url }) })) as Settings setSettings(result); setData((current) => current ? { ...current, settings: result } : current) setPreview(null) setNotice(result.enabled ? `Schedule saved. Next send: ${dateLabel(result.next_send_at)}.` : 'Settings saved. Scheduled delivery is paused.') setRevision((value) => value + 1) } catch (err) { setError(err instanceof Error ? err.message : 'Could not save the schedule.') } finally { setBusy('') } } const loadPreview = async () => { if (busy || !month) return const abort = new AbortController() previewController.current?.abort(); previewController.current = abort setBusy('preview'); setError(''); setNotice(''); setPreview(null) try { const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps/preview?month=${month}`, { signal: abort.signal })) as Preview if (!abort.signal.aborted) setPreview(result) } catch (err) { if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not prepare your preview.') } finally { if (!abort.signal.aborted) setBusy('') } } const sendTest = async () => { if (busy || !preview || preview.month !== month) return if (!testRequest.current || testRequest.current.month !== month) testRequest.current = { month, id: crypto.randomUUID() } setBusy('test'); setError(''); setNotice('') try { const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ month, request_id: testRequest.current.id }) })) setNotice(result.message); testRequest.current = null; setOffset(0); setRevision((value) => value + 1) } catch (err) { setError(err instanceof Error ? err.message : 'Could not queue your test.') } finally { setBusy('') } } return Email settings ↗}>
{error &&

{error}

} {notice &&

{notice}

} {!data && !error &&

Loading email recaps…

} {!data && error && } {data && <>
{data.settings.enabled ? 'Schedule running' : 'Schedule paused'}

{data.settings.enabled ? `Next send ${dateLabel(data.settings.next_send_at)}` : 'Start the schedule when you’re ready for monthly delivery.'}

{data.subscribers}confirmed {data.subscribers === 1 ? 'subscriber' : 'subscribers'}
Set the rhythm

Monthly schedule

Send the previous month’s report to users who have opted in and confirmed their email. All report periods and send times use UTC.

Starting or changing the schedule begins at its next future send time. Pausing cancels queued monthly emails.

{!data.ready &&

{data.detail} Review email settings ↗

}
Make it yours

Preview your recap

See your own viewing highlights in the email design. A test goes only to your confirmed profile email.

{dirty &&

Save your settings before previewing or sending a test.

}

One email. Your month.

  • Minutes, movies, episodes and requests
  • Changes from the previous month
  • Most watched titles and your longest run
  • A link to the full report and easy unsubscribe
Confirm your email in Profile ↗
{preview &&
Email preview

{preview.subject}

For {preview.email || 'your profile email'} · Preview links use your saved public address.

{previewMode === 'html' ?