diff --git a/.gitignore b/.gitignore index fb7a4a0..7dcf333 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ # production /dist +/services/assignment-notifier/dist +/services/assignment-notifier/data/store.json # vite pwa dev /dev-dist diff --git a/package.json b/package.json index fdcfd33..9e4e033 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "lint": "eslint ./src/", "check:type": "tsc --noEmit", "serve": "vite preview", - "prepare": "husky" + "prepare": "husky", + "notify:service": "yarn notify:service:build && node services/assignment-notifier/dist/index.js", + "notify:service:build": "tsc -p services/assignment-notifier/tsconfig.json" }, "dependencies": { "@apollo/client": "^3.8.7", diff --git a/services/assignment-notifier/README.md b/services/assignment-notifier/README.md new file mode 100644 index 0000000..85c7f89 --- /dev/null +++ b/services/assignment-notifier/README.md @@ -0,0 +1,44 @@ +# Assignment Notifier Service (scaffold) + +This is a single-service scaffold that polls WCIFs, detects assignment changes, and exposes subscription APIs for push notifications. + +## What it includes + +- WCIF polling loop +- Assignment hash snapshots and diffing +- Notification job generation with dedupe keys +- Local JSON store for snapshots/subscriptions +- Subscription API server (`/health`, `/subscriptions`) +- Placeholder push sender (ready for Web Push implementation) + +## Configure + +Create environment variables: + +- `WCA_OAUTH_TOKEN` - WCA token with WCIF access +- `WCIF_COMPETITION_IDS` - comma-separated competition IDs +- `WCIF_API_BASE_URL` (optional, defaults to WCA v0 API) +- `WCIF_POLL_INTERVAL_MS` (optional, defaults to 300000) +- `NOTIFIER_API_PORT` (optional, defaults to 8787) +- `VAPID_SUBJECT` (optional, e.g. `mailto:ops@example.com`) +- `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` + +## API + +- `GET /health` +- `GET /subscriptions` +- `POST /subscriptions` with `{ userId, endpoint, p256dh, auth }` +- `DELETE /subscriptions` with `{ endpoint }` + +## Run + +```bash +yarn notify:service +``` + +## Next steps + +- Replace `sendPushNotifications` with actual Web Push delivery. +- Move store to a persistent DB. +- Add upcoming-assignment reminder jobs. +- Add auth middleware for subscription API routes. diff --git a/services/assignment-notifier/data/.gitkeep b/services/assignment-notifier/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/services/assignment-notifier/src/apiServer.ts b/services/assignment-notifier/src/apiServer.ts new file mode 100644 index 0000000..577ae0a --- /dev/null +++ b/services/assignment-notifier/src/apiServer.ts @@ -0,0 +1,91 @@ +import http from 'http'; +import type { NotifierServiceConfig } from './config'; +import { deleteSubscription, readStore, upsertSubscription } from './store'; + +interface SubscriptionPayload { + endpoint?: string; + p256dh?: string; + auth?: string; + userId?: number; +} + +function parseBody(body: string): SubscriptionPayload { + try { + return JSON.parse(body) as SubscriptionPayload; + } catch { + return {}; + } +} + +export function startNotifierApiServer(config: NotifierServiceConfig) { + const server = http.createServer((req, res) => { + const url = req.url ?? ''; + + if (req.method === 'GET' && url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (req.method === 'GET' && url === '/subscriptions') { + void readStore().then((store) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(store.subscriptions)); + }); + return; + } + + if (req.method === 'POST' && url === '/subscriptions') { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const payload = parseBody(Buffer.concat(chunks).toString('utf8')); + + if (!payload.endpoint || !payload.p256dh || !payload.auth || !payload.userId) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing endpoint, p256dh, auth, or userId' })); + return; + } + + void upsertSubscription({ + userId: payload.userId, + endpoint: payload.endpoint, + p256dh: payload.p256dh, + auth: payload.auth, + }).then(() => { + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + return; + } + + if (req.method === 'DELETE' && url === '/subscriptions') { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const payload = parseBody(Buffer.concat(chunks).toString('utf8')); + if (!payload.endpoint) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing endpoint' })); + return; + } + + void deleteSubscription(payload.endpoint).then(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + }); + + server.listen(config.apiPort, () => { + console.log(`[push-notifier] API listening on :${config.apiPort}`); + }); + + return server; +} diff --git a/services/assignment-notifier/src/config.ts b/services/assignment-notifier/src/config.ts new file mode 100644 index 0000000..44473e4 --- /dev/null +++ b/services/assignment-notifier/src/config.ts @@ -0,0 +1,38 @@ +export interface NotifierServiceConfig { + wcifPollIntervalMs: number; + wcifApiBaseUrl: string; + apiToken: string; + competitionIds: string[]; + apiPort: number; + vapidSubject: string; + vapidPublicKey: string; + vapidPrivateKey: string; +} + +function readNumber(name: string, fallback: number) { + const raw = process.env[name]; + if (!raw) { + return fallback; + } + + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function getNotifierServiceConfig(): NotifierServiceConfig { + const competitionIds = (process.env.WCIF_COMPETITION_IDS ?? '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + + return { + wcifPollIntervalMs: readNumber('WCIF_POLL_INTERVAL_MS', 5 * 60 * 1000), + wcifApiBaseUrl: process.env.WCIF_API_BASE_URL ?? 'https://www.worldcubeassociation.org/api/v0', + apiToken: process.env.WCA_OAUTH_TOKEN ?? '', + competitionIds, + apiPort: readNumber('NOTIFIER_API_PORT', 8787), + vapidSubject: process.env.VAPID_SUBJECT ?? 'mailto:notifications@example.com', + vapidPublicKey: process.env.VAPID_PUBLIC_KEY ?? '', + vapidPrivateKey: process.env.VAPID_PRIVATE_KEY ?? '', + }; +} diff --git a/services/assignment-notifier/src/diff.ts b/services/assignment-notifier/src/diff.ts new file mode 100644 index 0000000..2aedf4c --- /dev/null +++ b/services/assignment-notifier/src/diff.ts @@ -0,0 +1,54 @@ +import { createHash } from 'crypto'; +import type { AssignmentSnapshot, NotificationJob, WcifPayload } from './types'; + +function hashAssignments(value: unknown) { + return createHash('sha256') + .update(JSON.stringify(value ?? null)) + .digest('hex'); +} + +export function createAssignmentSnapshots(wcif: WcifPayload): AssignmentSnapshot[] { + return wcif.persons + .filter((person) => person.wcaUserId) + .map((person) => ({ + competitionId: wcif.id, + personWcaUserId: person.wcaUserId, + assignmentsHash: hashAssignments(person.assignments), + fetchedAt: new Date().toISOString(), + })); +} + +export function buildNotificationJobs(params: { + previousSnapshots: AssignmentSnapshot[]; + nextSnapshots: AssignmentSnapshot[]; +}): NotificationJob[] { + const previousByUser = new Map( + params.previousSnapshots.map((snapshot) => [ + `${snapshot.competitionId}:${snapshot.personWcaUserId}`, + snapshot, + ]), + ); + + return params.nextSnapshots.flatMap((snapshot) => { + const id = `${snapshot.competitionId}:${snapshot.personWcaUserId}`; + const previous = previousByUser.get(id); + + if (!previous || previous.assignmentsHash === snapshot.assignmentsHash) { + return []; + } + + const dedupeKey = `assignment-update:${id}:${snapshot.assignmentsHash}`; + + return [ + { + id: `${Date.now()}:${id}`, + userId: snapshot.personWcaUserId, + competitionId: snapshot.competitionId, + title: 'New assignment update', + body: 'Your assignments were updated. Open the app to review the latest groups.', + dedupeKey, + createdAt: new Date().toISOString(), + }, + ]; + }); +} diff --git a/services/assignment-notifier/src/index.ts b/services/assignment-notifier/src/index.ts new file mode 100644 index 0000000..6780e25 --- /dev/null +++ b/services/assignment-notifier/src/index.ts @@ -0,0 +1,58 @@ +import { startNotifierApiServer } from './apiServer'; +import { getNotifierServiceConfig } from './config'; +import { buildNotificationJobs, createAssignmentSnapshots } from './diff'; +import { sendPushNotifications } from './pushSender'; +import { readStore, saveStore } from './store'; +import { fetchWcif } from './wcifClient'; + +async function runOnce() { + const config = getNotifierServiceConfig(); + const store = await readStore(); + + for (const competitionId of config.competitionIds) { + const wcif = await fetchWcif(competitionId, { + apiBaseUrl: config.wcifApiBaseUrl, + token: config.apiToken, + }); + + const nextSnapshots = createAssignmentSnapshots(wcif); + const previousSnapshots = store.snapshots.filter( + (snapshot) => snapshot.competitionId === competitionId, + ); + + const jobs = buildNotificationJobs({ previousSnapshots, nextSnapshots }).filter( + (job) => !store.deliveredDedupeKeys.includes(job.dedupeKey), + ); + + await sendPushNotifications({ + jobs, + subscriptions: store.subscriptions, + config, + }); + + store.snapshots = [ + ...store.snapshots.filter((snapshot) => snapshot.competitionId !== competitionId), + ...nextSnapshots, + ]; + store.deliveredDedupeKeys.push(...jobs.map((job) => job.dedupeKey)); + } + + await saveStore(store); +} + +async function main() { + const config = getNotifierServiceConfig(); + startNotifierApiServer(config); + + await runOnce(); + + if (process.env.NOTIFIER_RUN_ONCE === 'true') { + return; + } + + setInterval(() => { + void runOnce(); + }, config.wcifPollIntervalMs); +} + +void main(); diff --git a/services/assignment-notifier/src/pushSender.ts b/services/assignment-notifier/src/pushSender.ts new file mode 100644 index 0000000..c9e8276 --- /dev/null +++ b/services/assignment-notifier/src/pushSender.ts @@ -0,0 +1,33 @@ +import type { NotifierServiceConfig } from './config'; +import type { NotificationJob, PushSubscriptionRecord } from './types'; + +export async function sendPushNotifications(params: { + jobs: NotificationJob[]; + subscriptions: PushSubscriptionRecord[]; + config: NotifierServiceConfig; +}) { + if (!params.config.vapidPublicKey || !params.config.vapidPrivateKey) { + console.warn('[push-notifier] Skipping sends: missing VAPID keys'); + return; + } + + for (const job of params.jobs) { + const subscriptions = params.subscriptions.filter( + (subscription) => subscription.userId === job.userId, + ); + + for (const subscription of subscriptions) { + console.log( + '[push-notifier] TODO: send web push payload', + JSON.stringify({ + endpoint: subscription.endpoint, + userId: subscription.userId, + title: job.title, + body: job.body, + competitionId: job.competitionId, + dedupeKey: job.dedupeKey, + }), + ); + } + } +} diff --git a/services/assignment-notifier/src/store.ts b/services/assignment-notifier/src/store.ts new file mode 100644 index 0000000..11c68f6 --- /dev/null +++ b/services/assignment-notifier/src/store.ts @@ -0,0 +1,69 @@ +import { mkdir, readFile, writeFile } from 'fs/promises'; +import path from 'path'; +import type { PushSubscriptionRecord, ServiceStore } from './types'; + +const STORE_DIR = path.resolve(process.cwd(), 'services/assignment-notifier/data'); +const STORE_PATH = path.resolve(STORE_DIR, 'store.json'); + +async function ensureStore(): Promise { + try { + const content = await readFile(STORE_PATH, 'utf8'); + return JSON.parse(content) as ServiceStore; + } catch { + return { + snapshots: [], + subscriptions: [], + deliveredDedupeKeys: [], + }; + } +} + +export async function readStore() { + return ensureStore(); +} + +export async function saveStore(store: ServiceStore) { + await mkdir(STORE_DIR, { recursive: true }); + await writeFile(STORE_PATH, JSON.stringify(store, null, 2), 'utf8'); +} + +export async function upsertSubscription(params: { + userId: number; + endpoint: string; + p256dh: string; + auth: string; +}) { + const store = await readStore(); + const now = new Date().toISOString(); + const existing = store.subscriptions.find( + (subscription) => subscription.endpoint === params.endpoint, + ); + + if (existing) { + existing.userId = params.userId; + existing.p256dh = params.p256dh; + existing.auth = params.auth; + existing.updatedAt = now; + } else { + const next: PushSubscriptionRecord = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + userId: params.userId, + endpoint: params.endpoint, + p256dh: params.p256dh, + auth: params.auth, + createdAt: now, + updatedAt: now, + }; + store.subscriptions.push(next); + } + + await saveStore(store); +} + +export async function deleteSubscription(endpoint: string) { + const store = await readStore(); + store.subscriptions = store.subscriptions.filter( + (subscription) => subscription.endpoint !== endpoint, + ); + await saveStore(store); +} diff --git a/services/assignment-notifier/src/types.ts b/services/assignment-notifier/src/types.ts new file mode 100644 index 0000000..c4f674e --- /dev/null +++ b/services/assignment-notifier/src/types.ts @@ -0,0 +1,44 @@ +export interface PushSubscriptionRecord { + id: string; + userId: number; + endpoint: string; + p256dh: string; + auth: string; + createdAt: string; + updatedAt: string; +} + +export interface AssignmentSnapshot { + competitionId: string; + personWcaUserId: number; + assignmentsHash: string; + fetchedAt: string; +} + +export interface NotificationJob { + id: string; + userId: number; + competitionId: string; + title: string; + body: string; + dedupeKey: string; + createdAt: string; +} + +export interface WcifPerson { + wcaUserId: number; + name: string; + assignments?: unknown; +} + +export interface WcifPayload { + id: string; + name: string; + persons: WcifPerson[]; +} + +export interface ServiceStore { + snapshots: AssignmentSnapshot[]; + subscriptions: PushSubscriptionRecord[]; + deliveredDedupeKeys: string[]; +} diff --git a/services/assignment-notifier/src/wcifClient.ts b/services/assignment-notifier/src/wcifClient.ts new file mode 100644 index 0000000..a621f7d --- /dev/null +++ b/services/assignment-notifier/src/wcifClient.ts @@ -0,0 +1,18 @@ +import type { WcifPayload } from './types'; + +export async function fetchWcif( + competitionId: string, + options: { apiBaseUrl: string; token: string }, +): Promise { + const response = await fetch(`${options.apiBaseUrl}/competitions/${competitionId}/wcif`, { + headers: { + Authorization: `Bearer ${options.token}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch WCIF for ${competitionId}: ${response.status}`); + } + + return (await response.json()) as WcifPayload; +} diff --git a/services/assignment-notifier/tsconfig.json b/services/assignment-notifier/tsconfig.json new file mode 100644 index 0000000..8b63243 --- /dev/null +++ b/services/assignment-notifier/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/src/App.tsx b/src/App.tsx index 882b13e..5547ef2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import client from './apolloClient'; import { usePageTracking } from './hooks/usePageTracking'; import { CompetitionLayout } from './layouts/CompetitionLayout'; import { RootLayout } from './layouts/RootLayout'; +import { bootstrapAssignmentNotificationChecks } from './lib/notifications/assignmentNotifications'; import About from './pages/About'; import CompetitionEvents from './pages/Competition/ByGroup/Events'; import CompetitionGroup from './pages/Competition/ByGroup/Group'; @@ -81,6 +82,15 @@ const PsychSheet = () => { const Navigation = () => { usePageTracking(import.meta.env.VITE_GA_MEASUREMENT_ID); + const { user } = useAuth(); + + useEffect(() => { + if (!('Notification' in window) || Notification.permission !== 'granted') { + return; + } + + void bootstrapAssignmentNotificationChecks(user?.id); + }, [user?.id]); return ( diff --git a/src/hooks/useAssignmentNotifications/index.ts b/src/hooks/useAssignmentNotifications/index.ts new file mode 100644 index 0000000..203cfca --- /dev/null +++ b/src/hooks/useAssignmentNotifications/index.ts @@ -0,0 +1 @@ +export * from './useAssignmentNotifications'; diff --git a/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts b/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts new file mode 100644 index 0000000..144a3da --- /dev/null +++ b/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + bootstrapAssignmentNotificationChecks, + requestNotificationPermission, +} from '@/lib/notifications/assignmentNotifications'; + +type NotificationStatus = NotificationPermission | 'unsupported'; + +export function useAssignmentNotifications(userId?: number) { + const [status, setStatus] = useState(() => { + if (!('Notification' in window)) { + return 'unsupported'; + } + + return Notification.permission; + }); + const [isEnabling, setIsEnabling] = useState(false); + const [error, setError] = useState(null); + + const enableNotifications = useCallback(async () => { + setIsEnabling(true); + setError(null); + + try { + const permission = await requestNotificationPermission(); + setStatus(permission); + + if (permission === 'granted') { + await bootstrapAssignmentNotificationChecks(userId); + } + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Unable to enable notifications'); + } finally { + setIsEnabling(false); + } + }, [userId]); + + useEffect(() => { + if (status !== 'granted') { + return; + } + + void bootstrapAssignmentNotificationChecks(userId); + }, [status, userId]); + + return { + status, + notificationsEnabled: status === 'granted', + isEnabling, + error, + enableNotifications, + }; +} diff --git a/src/lib/notifications/assignmentNotifications.ts b/src/lib/notifications/assignmentNotifications.ts new file mode 100644 index 0000000..a74728c --- /dev/null +++ b/src/lib/notifications/assignmentNotifications.ts @@ -0,0 +1,149 @@ +const ASSIGNMENT_NOTIFICATION_TAG = 'assignment-reminder'; +const CHECK_INTERVAL_MINUTES = 15; +const DEFAULT_NOTIFICATION_SERVICE_URL = '/api/notifications'; + +interface PushSubscriptionJson { + endpoint: string; + keys?: { + p256dh?: string; + auth?: string; + }; +} + +export interface AssignmentNotificationPayload { + title: string; + body: string; + competitionId?: string; + registrantId?: number; + startsAt?: string; +} + +export async function requestNotificationPermission() { + if (!('Notification' in window)) { + return 'unsupported' as const; + } + + if (Notification.permission === 'granted') { + return 'granted' as const; + } + + const permission = await Notification.requestPermission(); + return permission; +} + +function toUint8Array(base64: string) { + const base64Padding = '='.repeat((4 - (base64.length % 4)) % 4); + const normalized = (base64 + base64Padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = window.atob(normalized); + const output = new Uint8Array(raw.length); + + for (let index = 0; index < raw.length; index += 1) { + output[index] = raw.charCodeAt(index); + } + + return output; +} + +function getNotificationServiceUrl() { + return import.meta.env.VITE_NOTIFICATION_SERVICE_URL ?? DEFAULT_NOTIFICATION_SERVICE_URL; +} + +export async function registerPushSubscription() { + if (!('serviceWorker' in navigator)) { + return null; + } + + const registration = await navigator.serviceWorker.ready; + const existing = await registration.pushManager.getSubscription(); + + if (existing) { + return existing; + } + + const vapidPublicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY; + if (!vapidPublicKey) { + throw new Error('Missing VITE_VAPID_PUBLIC_KEY'); + } + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: toUint8Array(vapidPublicKey), + }); + + return subscription; +} + +export async function syncSubscriptionWithBackend(subscription: PushSubscription, userId?: number) { + const payload = subscription.toJSON() as PushSubscriptionJson; + + await fetch(`${getNotificationServiceUrl()}/subscriptions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + userId, + endpoint: payload.endpoint, + p256dh: payload.keys?.p256dh, + auth: payload.keys?.auth, + }), + }); +} + +export async function showAssignmentNotification(payload: AssignmentNotificationPayload) { + if (!('serviceWorker' in navigator)) { + return; + } + + const registration = await navigator.serviceWorker.ready; + + await registration.showNotification(payload.title, { + body: payload.body, + tag: ASSIGNMENT_NOTIFICATION_TAG, + data: payload, + }); +} + +/** + * Placeholder for background assignment checks. + * + * In a follow-up we should: + * 1) register push subscription against a backend endpoint + * 2) run periodic background checks (where supported) + * 3) postMessage payloads into the SW and display user-facing notifications + */ +export async function bootstrapAssignmentNotificationChecks(userId?: number) { + if (!('serviceWorker' in navigator)) { + return; + } + + const registration = await navigator.serviceWorker.ready; + const subscription = await registerPushSubscription(); + if (subscription) { + await syncSubscriptionWithBackend(subscription, userId); + } + + if ('periodicSync' in registration) { + await ( + registration as ServiceWorkerRegistration & { + periodicSync: { + register: (tag: string, options: { minInterval: number }) => Promise; + }; + } + ).periodicSync.register('assignment-checks', { + minInterval: CHECK_INTERVAL_MINUTES * 60 * 1000, + }); + return; + } + + setInterval( + () => { + registration.active?.postMessage({ + type: 'ASSIGNMENT_CHECK_REQUEST', + requestedAt: new Date().toISOString(), + }); + }, + CHECK_INTERVAL_MINUTES * 60 * 1000, + ); +} diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index 54e917c..2c69df8 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -1,8 +1,13 @@ -import { Container } from '@/components'; +import { Button, Container } from '@/components'; +import { useAssignmentNotifications } from '@/hooks/useAssignmentNotifications'; +import { useAuth } from '@/providers/AuthProvider'; import { Theme, useUserSettings } from '@/providers/UserSettingsProvider'; export default function Settings() { const { theme, setTheme } = useUserSettings(); + const { user } = useAuth(); + const { status, notificationsEnabled, isEnabling, error, enableNotifications } = + useAssignmentNotifications(user?.id); const themeOptions: { value: Theme; label: string; description: string }[] = [ { value: 'light', label: 'Light', description: 'Always use light theme' }, @@ -12,30 +17,55 @@ export default function Settings() { return ( -

Settings

+
+

Settings

-
-

Appearance

+
+

Appearance

-
- - {themeOptions.map((option) => ( -
- setTheme(option.value)} - className="radio mt-1" - /> - -
- ))} +
+ + {themeOptions.map((option) => ( +
+ setTheme(option.value)} + className="radio" + /> + +
+ ))} +
+
+ +
+

Assignment notifications

+

+ Enable push notifications for new assignments and upcoming assignment reminders. +

+

Status: {status}

+ {!notificationsEnabled && status !== 'unsupported' && ( + + )} + {error &&

{error}

} + {status === 'unsupported' && ( +

This browser does not support the Notification API.

+ )}