/* Device-local reminders. Browsers cannot guarantee timed delivery after the site is closed. */ (() => { const KEY = 'lifeos_notifications_v1'; const SENT = 'lifeos_notifications_sent_v1'; const defaults = { memory: { enabled: false, time: '21:00' }, tasks: { enabled: false, time: '09:00' }, goals: { enabled: false, time: '08:00' } }; const kinds = Object.keys(defaults); const t = (key, values) => LifeI18n.t(`notifications.${key}`, values); const validTime = value => typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value); const today = now => `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; const read = key => { try { return JSON.parse(localStorage.getItem(key) || '{}') || {}; } catch { return {}; } }; const preferences = () => { const stored = read(KEY); return Object.fromEntries(kinds.map(kind => [kind, { enabled: stored[kind]?.enabled === true, time: validTime(stored[kind]?.time) ? stored[kind].time : defaults[kind].time }])); }; const supported = () => typeof Notification !== 'undefined' && typeof Notification.requestPermission === 'function' && isSecureContext; const status = () => !supported() ? 'unsupported' : Notification.permission; function markup() { const current = preferences(), permission = status(); return `
${t('title')}

${t(`status.${permission}`)}

${permission === 'default' ? `` : ''}

${t('openOnly')}

${kinds.map(kind => ` `).join('')}
${permission === 'granted' ? `` : ''}`; } async function show(title, body, app, tag) { const options = { body, icon: '/life-os-icon.svg', tag, data: { app } }; if ('serviceWorker' in navigator) { const registration = await navigator.serviceWorker.getRegistration(); if (registration?.showNotification) { await registration.showNotification(title, options); return; } } const notification = new Notification(title, options); notification.onclick = () => { window.focus(); openApp(app); notification.close(); }; } function content(kind, date) { if (kind === 'memory') { if (mems().some(memory => memory.date === date)) return null; return { title: t('memory.title'), body: t('memory.body'), app: 'today' }; } const board = sys().tasksBoard || {}; if (kind === 'tasks') { const pending = (board.tasks || []).filter(task => !task.done && task.dueDate && task.dueDate <= date); if (!pending.length) return null; return { title: t('tasks.title'), body: t('tasks.body', { count: pending.length }), app: 'tasks' }; } const goals = (board.goals || []).filter(goal => !goal.done && goal.targetDate).sort((a, b) => a.targetDate.localeCompare(b.targetDate)); if (!goals.length) return null; const goal = goals.find(item => item.targetDate >= date) || goals.at(-1); const [year, month, day] = goal.targetDate.split('-').map(Number), [currentYear, currentMonth, currentDay] = date.split('-').map(Number); const days = Math.round((Date.UTC(year, month - 1, day) - Date.UTC(currentYear, currentMonth - 1, currentDay)) / 864e5); return { title: t('goals.title'), body: t(days < 0 ? 'goals.overdue' : days === 0 ? 'goals.today' : 'goals.days', { name: goal.title, count: Math.abs(days) }), app: 'tasks' }; } let checking = false; async function check(now = new Date()) { if (checking || status() !== 'granted') return; checking = true; try { const date = today(now), minute = now.getHours() * 60 + now.getMinutes(), current = preferences(); for (const kind of kinds) { const preference = current[kind]; if (!preference.enabled) continue; const [hour, minutes] = preference.time.split(':').map(Number), elapsed = minute - hour * 60 - minutes; if (elapsed < 0 || elapsed > 60) continue; const key = `${date}:${kind}`, sent = read(SENT); if (sent[key]) continue; const reminder = content(kind, date); if (!reminder) continue; try { await show(reminder.title, reminder.body, reminder.app, `lifeos-${kind}-${date}`); const latest = read(SENT); localStorage.setItem(SENT, JSON.stringify({ ...Object.fromEntries(Object.entries(latest).filter(([item]) => item.startsWith(date + ':'))), [key]: true })); } catch (error) { console.info('Life OS notification unavailable', error); } } } finally { checking = false; } } async function request() { if (!supported()) return; try { await Notification.requestPermission(); } catch {} window.renderApp?.('system'); check(); } async function test() { if (status() !== 'granted') return; try { await show(t('test.title'), t('test.body'), 'system', 'lifeos-test'); } catch { window.toast?.(t('test.failed')); } } document.addEventListener('change', event => { const field = event.target.closest('[data-notification-kind][data-field]'); if (!field || !kinds.includes(field.dataset.notificationKind)) return; const current = preferences(), kind = field.dataset.notificationKind; if (field.dataset.field === 'time' && validTime(field.value)) current[kind].time = field.value; if (field.dataset.field === 'enabled') current[kind].enabled = field.checked; localStorage.setItem(KEY, JSON.stringify(current)); check(); }); window.addEventListener('focus', () => check()); document.addEventListener('visibilitychange', () => { if (!document.hidden) check(); }); window.addEventListener('load', () => check()); navigator.serviceWorker?.addEventListener('message', event => { if (event.data?.type === 'lifeos-open-app' && ['today', 'tasks', 'system'].includes(event.data.app)) openApp(event.data.app); }); const requestedApp = new URL(location.href).searchParams.get('app'); if (['today', 'tasks', 'system'].includes(requestedApp)) { window.addEventListener('load', () => openApp(requestedApp), { once: true }); const clean = new URL(location.href); clean.searchParams.delete('app'); history.replaceState(null, '', clean); } setInterval(() => check(), 30_000); window.LifeOSNotifications = { markup, request, test, check }; const style = document.createElement('style'); style.textContent = `.notification-settings{grid-column:1/-1}.notification-settings>.sub{line-height:1.5}.notification-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:14px 0}.notification-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:14px;border:1px solid var(--line);border-radius:10px;background:var(--panel2)}.notification-row span{display:grid;gap:5px}.notification-row strong{font-size:14px}.notification-row small{font-size:12px;line-height:1.45;color:var(--muted)}.notification-row input{flex:none;width:19px;height:19px;accent-color:var(--accent)}.notification-time{display:grid;gap:7px;padding:0 12px;color:var(--muted);font-size:13px}.notification-time input{width:100%;max-width:170px;padding:8px;border:1px solid var(--line);border-radius:7px;color:var(--ink);background:var(--panel2);font:inherit}@media(max-width:760px){.notification-list{grid-template-columns:1fr}.notification-time{margin-bottom:8px}}`; document.head.append(style); })();