(function(root, factory) { if (typeof module === 'object' && module.exports) module.exports = factory; else root.createPushNotifications = factory; })(typeof self !== 'undefined' ? self : this, function createPushNotifications({ control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, startDayControl, startDayStatus, startDayHour, deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines, notification, serviceWorker, fetchJson, }) { let configuration = null; let pendingIntent = null; let recoveryPromise = null; function blockedReadiness() { return { state:'blocked', detail:'Notifications are blocked. Allow them in browser settings, then check again.', actionLabel:'Check again', }; } function pendingRecoveryReadiness() { return notification.permission === 'granted' ? {state:'blocked', detail:'Notification permission changed. Check again to finish setup.', actionLabel:'Check again'} : blockedReadiness(); } function notificationReadiness() { if (!configuration?.available || control?.disabled) { return {state:'unavailable', detail:status?.textContent || 'Update notifications are unavailable.'}; } if (configuration.subscribed) { return {state:'complete', detail:'New update notifications are enabled.'}; } if (pendingIntent === 'updates') return pendingRecoveryReadiness(); if (notification.permission === 'denied') return blockedReadiness(); return {state:'incomplete', detail:status?.textContent || 'New update notifications are off.'}; } function renderDeliveryHealth() { const health = Object.values(configuration?.delivery_health || {}); const degraded = health.find(item => item?.state === 'degraded'); if (testControl) testControl.hidden = !configuration?.subscribed; if (configuration?.subscribed && degraded) { const count = Number(degraded.consecutive_failures || 1); status.textContent = `Update notifications need attention after ${count} failed ${count === 1 ? 'delivery' : 'deliveries'}. Send a test notification.`; return; } status.textContent = configuration?.subscribed ? 'New update notifications enabled for this device.' : 'New update notifications are off for this device.'; } async function testDelivery() { if (!testControl) return; testControl.disabled = true; status.textContent = 'Sending a test notification…'; try { await fetchJson('api/v1/push-subscription/test', {method:'POST'}); configuration.delivery_health = {unread:{state:'healthy',consecutive_failures:0}}; status.textContent = 'Test delivered. Update notifications are working on this device.'; } catch (error) { status.textContent = 'Test delivery failed. Check your connection, then try again.'; } finally { testControl.disabled = false; } } function formattedHour(value) { return `${String(Number(value)).padStart(2, '0')}:00`; } function enabledDeadlineText(hour, days) { const value = Number(days ?? 2); const horizon = value === 0 ? 'due today' : `next ${value} days`; return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`; } function renderDeadlineSnooze() { if (!deadlineSnooze) return; const wakeAt = Number(configuration?.snoozed_until || 0); deadlineSnooze.hidden = !wakeAt; if (wakeAt && deadlineSnoozeStatus) { const localTime = new Intl.DateTimeFormat(undefined, { hour:'numeric', minute:'2-digit', }).format(new Date(wakeAt * 1000)); deadlineSnoozeStatus.textContent = `Deadline reminders snoozed until ${localTime}.`; } } async function reviewSnoozedDeadlines() { deadlineSnoozeReview.disabled = true; try { await fetchJson('api/v1/push-subscription/deadlines/snooze', {method:'DELETE'}); configuration.snoozed_until = null; renderDeadlineSnooze(); onReviewDeadlines?.(); } catch (error) { deadlineSnoozeStatus.textContent = 'Could not resume deadline reminders. Check your connection and try again.'; } finally { deadlineSnoozeReview.disabled = false; } } function deadlineReadiness() { if (!configuration?.available || deadlineControl?.disabled) { return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'}; } if (pendingIntent === 'deadline') return pendingRecoveryReadiness(); return configuration.deadline_enabled ? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)} : {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'}; } function applicationServerKey(value) { const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4); if (typeof atob === 'function') { return Uint8Array.from(atob(padded), character => character.charCodeAt(0)); } return Uint8Array.from(Buffer.from(padded, 'base64')); } async function disable() { const registration = await serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); await fetchJson('api/v1/push-subscription', {method:'DELETE'}); await subscription?.unsubscribe?.(); control.checked = false; if (testControl) testControl.hidden = true; if (deadlineControl) deadlineControl.checked = false; if (startDayControl) startDayControl.checked = false; configuration.subscribed = false; configuration.deadline_enabled = false; configuration.start_day_enabled = false; pendingIntent = null; status.textContent = 'New update notifications are off for this device.'; if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.'; } async function ensureSubscription() { const permission = notification.permission === 'granted' ? 'granted' : await notification.requestPermission(); if (permission !== 'granted') { control.checked = false; status.textContent = blockedReadiness().detail; return null; } const registration = await serviceWorker.ready; let subscription = await registration.pushManager.getSubscription(); if (!subscription) { subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: applicationServerKey(configuration.public_key), }); } await fetchJson('api/v1/push-subscription', { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(subscription.toJSON()), }); control.checked = true; status.textContent = 'New update notifications enabled for this device.'; configuration.subscribed = true; if (testControl) testControl.hidden = false; return subscription; } async function enable() { pendingIntent = 'updates'; const subscription = await ensureSubscription(); if (subscription) pendingIntent = null; return subscription; } async function change() { control.disabled = true; try { if (control.checked) await enable(); else await disable(); } catch (error) { control.checked = !control.checked; status.textContent = 'Could not change update notifications. Check your connection and try again.'; } finally { control.disabled = false; } } async function changeDeadline() { deadlineControl.disabled = true; if (deadlineHour) deadlineHour.disabled = true; if (deadlineDays) deadlineDays.disabled = true; try { const registration = await serviceWorker.ready; let subscription = await registration.pushManager.getSubscription(); if (deadlineControl.checked) pendingIntent = 'deadline'; if (deadlineControl.checked && !subscription) subscription = await ensureSubscription(); if (deadlineControl.checked && !subscription) { deadlineControl.checked = false; deadlineStatus.textContent = status.textContent; return false; } const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9); const reminderDays = Number(deadlineDays?.value ?? configuration?.reminder_days ?? 2); await fetchJson('api/v1/push-subscription/deadlines', { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour, reminder_days:reminderDays}), }); configuration.deadline_enabled = deadlineControl.checked; configuration.reminder_hour = reminderHour; configuration.reminder_days = reminderDays; configuration.timezone = timezone; pendingIntent = null; deadlineStatus.textContent = deadlineControl.checked ? enabledDeadlineText(reminderHour, reminderDays) : 'Deadline reminders are off for this device.'; return true; } catch (error) { deadlineControl.checked = !deadlineControl.checked; deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.'; return false; } finally { deadlineControl.disabled = false; if (deadlineHour) deadlineHour.disabled = false; if (deadlineDays) deadlineDays.disabled = false; } } async function changeStartDay() { startDayControl.disabled = true; if (startDayHour) startDayHour.disabled = true; try { const registration = await serviceWorker.ready; let subscription = await registration.pushManager.getSubscription(); if (startDayControl.checked) pendingIntent = 'start-day'; if (startDayControl.checked && !subscription) subscription = await ensureSubscription(); if (startDayControl.checked && !subscription) { startDayControl.checked = false; startDayStatus.textContent = status.textContent; return false; } const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; const reminderHour = Number(startDayHour?.value ?? configuration?.start_day_reminder_hour ?? 9); await fetchJson('api/v1/push-subscription/start-day', { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:startDayControl.checked, timezone, reminder_hour:reminderHour}), }); configuration.start_day_enabled = startDayControl.checked; configuration.start_day_timezone = timezone; configuration.start_day_reminder_hour = reminderHour; pendingIntent = null; startDayStatus.textContent = startDayControl.checked ? `Start-day reminder enabled for ${formattedHour(reminderHour)} local time.` : 'Start-day reminders are off for this device.'; return true; } catch (_error) { startDayControl.checked = !startDayControl.checked; startDayStatus.textContent = 'Could not change start-day reminders. Check your connection and try again.'; return false; } finally { startDayControl.disabled = false; if (startDayHour) startDayHour.disabled = false; } } async function enableDeadline() { deadlineControl.checked = true; return changeDeadline(); } async function recoverPermission(intent = null) { if (!pendingIntent && ['updates', 'deadline', 'start-day'].includes(intent)) pendingIntent = intent; if (!pendingIntent || notification.permission !== 'granted') return false; if (recoveryPromise) return recoveryPromise; recoveryPromise = (async () => { if (pendingIntent === 'deadline') { deadlineControl.checked = true; return changeDeadline(); } if (pendingIntent === 'start-day') { startDayControl.checked = true; return changeStartDay(); } return Boolean(await enable()); })(); try { return await recoveryPromise; } finally { recoveryPromise = null; } } async function init() { if (!control || !notification || !serviceWorker) return; control.addEventListener('change', change); testControl?.addEventListener('click', testDelivery); deadlineControl?.addEventListener('change', changeDeadline); startDayControl?.addEventListener('change', changeStartDay); deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines); configuration = await fetchJson('api/v1/push-subscription'); if (!configuration.available) { control.disabled = true; if (deadlineControl) deadlineControl.disabled = true; if (startDayControl) startDayControl.disabled = true; status.textContent = 'New update notifications are not available on this server.'; return; } control.checked = Boolean(configuration.subscribed); if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled); if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled); if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9); if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2); if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9); renderDeliveryHealth(); if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled ? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days) : 'Deadline reminders are off for this device.'; if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled ? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.` : 'Start-day reminders are off for this device.'; renderDeadlineSnooze(); } return {init, change, changeDeadline, changeStartDay, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission}; });