125 lines
8.0 KiB
JavaScript
125 lines
8.0 KiB
JavaScript
/* 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 `<div class="eyebrow">${t('title')}</div><p class="sub">${t(`status.${permission}`)}</p>
|
|
${permission === 'default' ? `<button class="secondary" type="button" onclick="LifeOSNotifications.request()">${t('allow')}</button>` : ''}
|
|
<p class="sub">${t('openOnly')}</p><div class="notification-list">${kinds.map(kind => `<label class="notification-row"><span><strong>${t(`${kind}.label`)}</strong><small>${t(`${kind}.description`)}</small></span><input type="checkbox" data-notification-kind="${kind}" data-field="enabled" ${current[kind].enabled ? 'checked' : ''} aria-label="${t(`${kind}.label`)}"></label>
|
|
<label class="notification-time">${t('time', { name: t(`${kind}.label`) })}<input type="time" data-notification-kind="${kind}" data-field="time" value="${current[kind].time}" aria-label="${t('time', { name: t(`${kind}.label`) })}"></label>`).join('')}</div>
|
|
${permission === 'granted' ? `<button class="secondary" type="button" onclick="LifeOSNotifications.test()">${t('sendTest')}</button>` : ''}`;
|
|
}
|
|
|
|
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);
|
|
})();
|