/* 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);
})();