// 主入口:初始化、交互、提醒与 PWA (function () { let current = new Date(); let monthEvents = []; let monthOccurrences = []; const monthCache = new Map(); // 进程内临时集:已提醒过的事件键,避免重复提醒(不落地) const notifiedKeys = new Set(); const scheduledKeys = new Set(); let heartbeatTimer = null; const WINDOW_MS = 2 * 60 * 1000; // 2 分钟容错窗口 let autoRefreshTimer = null; let lastUserActionTs = Date.now(); const AUTORELOAD_MS = 30 * 60 * 1000; async function init() { document.getElementById('modeLabel').textContent = AppConfig.mode; // 通知权限请求(可延迟到首次需要时) if ('Notification' in window) { try { if (Notification.permission === 'default') { await Notification.requestPermission(); } } catch (_) {} } // 禁用 Service Worker,确保不使用任何本地缓存 await unregisterServiceWorker(); bindNav(); bindReload(); bindForm(); bindAutoRefresh(); await loadMonth(current.getFullYear(), current.getMonth() + 1); startReminderHeartbeat(); } function bindNav() { document.getElementById('btnPrev').addEventListener('click', async () => { current.setMonth(current.getMonth() - 1); await loadMonth(current.getFullYear(), current.getMonth() + 1); }); document.getElementById('btnNext').addEventListener('click', async () => { current.setMonth(current.getMonth() + 1); await loadMonth(current.getFullYear(), current.getMonth() + 1); }); } function bindReload() { const btn = document.getElementById('btnReload'); if (!btn) return; btn.addEventListener('click', async () => { lastUserActionTs = Date.now(); try { UI.showGlobalModal('正在刷新日历...'); try { UI.appendGlobalModalLog('清空内存缓存'); } catch (_) {} monthCache.clear(); notifiedKeys.clear(); scheduledKeys.clear(); try { UI.appendGlobalModalLog('注销 Service Worker'); } catch (_) {} await unregisterServiceWorker(); if ('caches' in window) { try { UI.appendGlobalModalLog('清除浏览器缓存存储'); const keys = await caches.keys(); await Promise.all(keys.map(k => caches.delete(k))); } catch (_) {} } } catch (_) {} await loadMonth(current.getFullYear(), current.getMonth() + 1); try { UI.appendGlobalModalLog('刷新流程完成'); } catch (_) {} try { UI.completeGlobalModal('刷新完成'); } catch (_) {} }); } async function loadMonth(year, month) { UI.showGlobalModal('正在查询当月数据...'); try { UI.appendGlobalModalLog('开始查询 ' + year + '-' + month + ' 数据'); } catch (_) {} let ok = true; console.time('loadMonthTotal'); try { const key = `${year}-${month}`; const cached = monthCache.get(key); if (cached) { monthEvents = cached; } else { console.time('fetchAPI'); const remote = await SyncAPI.listByMonth(year, month); console.timeEnd('fetchAPI'); console.log('API returned events:', remote ? remote.length : 0); monthEvents = remote; monthCache.set(key, remote); } const prevY = month === 1 ? year - 1 : year; const prevM = month === 1 ? 12 : month - 1; const prevKey = `${prevY}-${prevM}`; const prevCached = monthCache.get(prevKey); let prevEvents = []; if (prevCached) { prevEvents = prevCached; } else { const prevRemote = await SyncAPI.listByMonth(prevY, prevM); prevEvents = prevRemote; monthCache.set(prevKey, prevRemote); } const merged = []; const seen = new Set(); const pushUnique = (list) => { list.forEach(e => { if (!e) return; const k = e.id ? `id:${e.id}` : `k:${e.title || ''}|${e.start || ''}|${e.end || ''}`; if (seen.has(k)) return; seen.add(k); merged.push(e); }); }; pushUnique(monthEvents); pushUnique(prevEvents); monthEvents = merged; } catch (err) { console.error('远程拉取失败', err); try { UI.appendGlobalModalLog('错误:远程拉取失败 ' + (err && err.message ? err.message : '')); } catch (_) {} monthEvents = []; alert('远程拉取失败,请稍后重试'); ok = false; } try { UI.appendGlobalModalLog('开始展开当月循环事件'); } catch (_) {} console.time('expandEvents'); monthOccurrences = expandEventsForMonth(monthEvents, year, month); console.timeEnd('expandEvents'); console.log('Expanded occurrences:', monthOccurrences.length); try { UI.appendGlobalModalLog('展开完成,实例数=' + monthOccurrences.length); } catch (_) {} try { UI.appendGlobalModalLog('开始渲染日历'); } catch (_) {} console.time('renderCalendar'); // 使用 requestAnimationFrame 给 UI 线程喘息机会,避免卡死 await new Promise(r => requestAnimationFrame(() => { UI.renderCalendar(year, month, monthOccurrences); r(); })); console.timeEnd('renderCalendar'); try { UI.appendGlobalModalLog('渲染完成'); } catch (_) {} // 立即补偿提醒(捕捉刚到点或即将到点的事件) catchUpNotifications(monthOccurrences); // 设置未来提醒的定时器 scheduleNotifications(monthOccurrences); // 刷新后重启心跳,确保新数据生效 startReminderHeartbeat(); prefetchAdjacentMonths(year, month); if (ok) { try { UI.appendGlobalModalLog('查询完成'); } catch (_) {} try { UI.completeGlobalModal('查询完成'); } catch (_) {} } else { try { UI.appendGlobalModalLog('查询失败'); } catch (_) {} try { UI.updateGlobalModalMessage('查询失败'); } catch (_) {} } } // 顶层自执行函数内新增:切换提醒输入与构造循环/提醒 function updateReminderInputs() { const type = document.getElementById('recurrenceType').value; const enabled = document.getElementById('enableReminder').checked; const dtWrap = document.getElementById('remindDateTime'); const tWrap = document.getElementById('remindTime'); const opts = document.getElementById('recurrenceOptions'); const wgroup = document.getElementById('weekdayGroup'); dtWrap.classList.toggle('d-none', !enabled || (type === 'DAILY' || type === 'WEEKLY')); tWrap.classList.toggle('d-none', !enabled || (type === 'NONE')); opts.classList.toggle('d-none', type === 'NONE'); wgroup.classList.toggle('d-none', type !== 'WEEKLY'); } function composeRecurrence() { const type = document.getElementById('recurrenceType').value; if (type === 'NONE') return ''; const interval = Math.max(1, parseInt(document.getElementById('recurrenceInterval').value || '1', 10)); if (type === 'DAILY') return `DAILY;interval=${interval}`; const checked = Array.from(document.querySelectorAll('#weekdayGroup [data-weekday]')) .filter(el => el.checked).map(el => parseInt(el.getAttribute('data-weekday'), 10)).sort((a, b) => a - b); const by = checked.length ? `;by=${checked.join(',')}` : ''; return `WEEKLY;interval=${interval}${by}`; } function combineDateAndTime(startISO, timeStr) { const base = new Date(startISO); const [hh, mmRaw] = (timeStr || '00:00').split(':').map(s => parseInt(s, 10)); const q = Math.round((mmRaw || 0) / 15) * 15; const mm = Math.min(45, Math.max(0, q)); base.setHours(hh || 0, mm, 0, 0); return base.toISOString(); } // 可视兜底(Toast):即使系统静默通知也显示 function ensureToastContainer() { let c = document.getElementById('toastContainer'); if (!c) { c = document.createElement('div'); c.id = 'toastContainer'; c.className = 'toast-container'; document.body.appendChild(c); } return c; } function showToast(title, body) { try { const c = ensureToastContainer(); const item = document.createElement('div'); item.className = 'inapp-toast'; const strong = document.createElement('strong'); strong.textContent = title || '提醒'; const p = document.createElement('div'); p.textContent = body || ''; item.appendChild(strong); item.appendChild(p); c.appendChild(item); setTimeout(() => { try { item.classList.add('fadeout'); } catch (_) {} setTimeout(() => { try { c.removeChild(item); } catch (_) {} }, 300); }, 5000); } catch (_) {} } function triggerNotify(e) { const title = '提醒:' + (e.title || ''); const body = `${new Date(e.start).toLocaleString()} - ${new Date(e.end).toLocaleString()}`; if ('Notification' in window && Notification.permission === 'granted') { try { new Notification(title, { body }); } catch (_) {} } showToast(title, body); } function bindForm() { const form = document.getElementById('eventForm'); const btnReset = document.getElementById('btnReset'); const btnDelete = document.getElementById('btnDelete'); const btnSubmit = form.querySelector('[type="submit"]'); // 新增:循环与提醒输入联动 document.getElementById('recurrenceType').addEventListener('change', updateReminderInputs); document.getElementById('enableReminder').addEventListener('change', updateReminderInputs); updateReminderInputs(); const inputsToSnap = ['start','end','remindDateTime','remindTime']; inputsToSnap.forEach(id => { const el = document.getElementById(id); if (!el) return; ['change','blur'].forEach(evt => { el.addEventListener(evt, () => { try { if (id === 'remindTime') { const v = el.value || ''; const parts = v.split(':'); if (parts.length >= 2) { const h = parseInt(parts[0] || '0', 10); const mRaw = parseInt(parts[1] || '0', 10); const m = Math.min(45, Math.max(0, Math.round(mRaw / 15) * 15)); el.value = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; } } else if (el.value) { const d = new Date(el.value); const q = Math.round(d.getMinutes() / 15) * 15; d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0); const pad = (n) => String(n).padStart(2, '0'); el.value = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } } catch (_) {} }, { passive: true }); }); }); form.addEventListener('submit', async (ev) => { ev.preventDefault(); // 启用原生表单校验(required 等) if (!form.checkValidity()) { form.reportValidity(); return; } // 保存按钮进入加载态 btnSubmit.disabled = true; const originalText = btnSubmit.textContent; btnSubmit.textContent = '保存中...'; let data; try { UI.showGlobalModal('正在保存...'); try { UI.appendGlobalModalLog('收集表单数据'); } catch (_) {} data = collectForm(); if (!data.title || !data.start) { try { UI.appendGlobalModalLog('错误:缺少标题或开始时间'); } catch (_) {} alert('请填写标题与开始时间'); return; } if (data.id) { const base = (form.eventLoaded && form.eventLoaded.id === data.id) ? form.eventLoaded : {}; const updated = Models.normalizeForUpdate({ ...base, ...data }); try { UI.appendGlobalModalLog('准备更新事件'); } catch (_) {} await SyncAPI.upsertEvent(updated); } else { const evNew = Models.createEvent(data); try { UI.appendGlobalModalLog('准备创建新事件'); } catch (_) {} await SyncAPI.upsertEvent(evNew); } // 刷新当前月 try { UI.appendGlobalModalLog('刷新当月视图'); } catch (_) {} await loadMonth(current.getFullYear(), current.getMonth() + 1); resetForm(); try { UI.appendGlobalModalLog('保存成功'); } catch (_) {} try { UI.completeGlobalModal('保存成功'); } catch (_) {} } catch (err) { console.error('保存失败', err); alert('保存失败:' + (err && err.message ? err.message : err)); try { UI.appendGlobalModalLog('错误:保存失败 ' + (err && err.message ? err.message : '')); } catch (_) {} try { UI.updateGlobalModalMessage('保存失败'); } catch (_) {} } finally { btnSubmit.disabled = false; btnSubmit.textContent = originalText; } }); btnReset.addEventListener('click', resetForm); btnDelete.addEventListener('click', async () => { const id = document.getElementById('eventId').value; if (!id) return; if (!confirm('确认删除该事件吗?')) return; try { UI.showGlobalModal('正在删除...'); try { UI.appendGlobalModalLog('准备删除事件 ' + id); } catch (_) {} try { UI.appendGlobalModalLog('调用 deleteEvent'); } catch (_) {} await SyncAPI.deleteEvent(id); } catch (e) { console.error('远端删除失败', e); alert('删除失败:' + (e && e.message ? e.message : e)); try { UI.appendGlobalModalLog('错误:删除失败 ' + (e && e.message ? e.message : '')); } catch (_) {} try { UI.updateGlobalModalMessage('删除失败'); } catch (_) {} return; } try { UI.appendGlobalModalLog('删除成功,刷新当月视图'); } catch (_) {} await loadMonth(current.getFullYear(), current.getMonth() + 1); resetForm(); try { UI.appendGlobalModalLog('删除完成'); } catch (_) {} try { UI.completeGlobalModal('删除完成'); } catch (_) {} }); } function collectForm() { const id = document.getElementById('eventId').value || ''; const title = document.getElementById('title').value || ''; const content = document.getElementById('content').value || ''; const completed = document.getElementById('completedFlag').checked; const startInput = document.getElementById('start').value; const start = startInput ? new Date(startInput) : null; const endInput = document.getElementById('end').value; const end = endInput ? new Date(endInput) : null; const type = document.getElementById('recurrenceType').value; const enableReminder = document.getElementById('enableReminder').checked; const remindDateTime = document.getElementById('remindDateTime').value; const remindTime = document.getElementById('remindTime').value; const recurrence = composeRecurrence(); function roundToQuarter(d) { if (!d) return d; const q = Math.round(d.getMinutes() / 15) * 15; d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0); return d; } if (start) roundToQuarter(start); if (end) roundToQuarter(end); let remindAt = ''; if (enableReminder) { if (type === 'NONE') { remindAt = remindDateTime ? roundToQuarter(new Date(remindDateTime)).toISOString() : ''; } else { remindAt = (start && remindTime) ? combineDateAndTime(start.toISOString(), remindTime) : ''; } } return { id, title, content, completed, allDay: false, start: start ? start.toISOString() : '', end: end ? end.toISOString() : '', remindAt, recurrence }; } function resetForm() { const form = document.getElementById('eventForm'); form.reset(); document.getElementById('eventId').value = ''; document.getElementById('btnDelete').classList.add('d-none'); document.getElementById('dayEvents').innerHTML = ''; } function scheduleNotifications(events) { const now = Date.now(); const next24h = now + 24 * 3600_000; events.forEach(e => { if (e.completed) return; const upcoming = computeUpcomingReminders(e, now, next24h); upcoming.forEach(remindIso => { const ts = new Date(remindIso).getTime(); const delay = ts - now; if (delay > 0) { const key = `${e.id}|${new Date(remindIso).toISOString()}`; if (notifiedKeys.has(key) || scheduledKeys.has(key)) return; scheduledKeys.add(key); setTimeout(() => { try { if (notifiedKeys.has(key)) return; triggerNotify({ ...e, remindAt: remindIso }); notifiedKeys.add(key); scheduledKeys.delete(key); } catch (_) {} }, delay); } }); }); } // 立即补偿:加载数据后,立刻检查 2 分钟窗口内的到期提醒 function catchUpNotifications(events) { const now = Date.now(); const since = now - WINDOW_MS; try { events.forEach(e => { if (e.completed) return; const windowReminders = computeUpcomingReminders(e, since, now); windowReminders.forEach(remindIso => { const key = `${e.id}|${new Date(remindIso).toISOString()}`; if (!notifiedKeys.has(key)) { triggerNotify({ ...e, remindAt: remindIso }); notifiedKeys.add(key); } }); }); } catch (_) {} } function startReminderHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } // 每分钟检查一次,捕捉睡眠/时间漂移导致的错过提醒 heartbeatTimer = setInterval(() => { const now = Date.now(); const since = now - WINDOW_MS; try { monthOccurrences.forEach(e => { if (e.completed) return; const windowReminders = computeUpcomingReminders(e, since, now); windowReminders.forEach(remindIso => { const key = `${e.id}|${new Date(remindIso).toISOString()}`; if (!notifiedKeys.has(key)) { triggerNotify({ ...e, remindAt: remindIso }); notifiedKeys.add(key); } }); }); } catch (_) {} }, 60 * 1000); } function bindAutoRefresh() { ['click','keydown','mousemove','touchstart','wheel'].forEach(ev => { document.addEventListener(ev, () => { lastUserActionTs = Date.now(); }, { passive: true }); }); if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; } autoRefreshTimer = setInterval(() => { if (document.hidden) return; const idleMs = Date.now() - lastUserActionTs; if (idleMs >= AUTORELOAD_MS) { const y = current.getFullYear(); const m = current.getMonth() + 1; loadMonth(y, m); } }, 60 * 1000); } async function unregisterServiceWorker() { try { if ('serviceWorker' in navigator) { const regs = await navigator.serviceWorker.getRegistrations(); for (const r of regs) { try { await r.unregister(); } catch (_) {} } } } catch (_) {} } // 已停用后台同步与本地缓存 window.addEventListener('DOMContentLoaded', init); // 不再开启后台同步 })(); function parseRecurrenceStr(str) { const r = (str || '').trim(); if (!r) return { type: 'NONE', interval: 0, by: [] }; const type = r.startsWith('DAILY') ? 'DAILY' : (r.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE'); const mInt = /interval=(\d+)/.exec(r); const interval = mInt ? Math.max(1, parseInt(mInt[1], 10)) : 1; const mBy = /by=([0-9,]+)/.exec(r); const by = mBy ? mBy[1].split(',').map(s => parseInt(s, 10)).filter(n => n>=1 && n<=7) : []; return { type, interval, by }; } function setTimeFromIso(dateObj, timeSourceIso) { const d = new Date(dateObj.getTime()); const t = timeSourceIso ? new Date(timeSourceIso) : null; const hh = t ? t.getHours() : 0; const mm = t ? t.getMinutes() : 0; d.setHours(hh, mm, 0, 0); return d.toISOString(); } function expandEventsForMonth(events, year, month) { const start = new Date(Date.UTC(year, month - 1, 1)); const end = new Date(Date.UTC(year, month, 0, 23, 59, 59, 999)); const out = []; events.forEach(e => { if (!e || e.deleted) return; const rec = parseRecurrenceStr(e.recurrence); const evStart = new Date(e.start); const evEnd = e.end ? new Date(e.end) : new Date(evStart.getTime() + 60*60*1000); if (rec.type === 'NONE') { out.push(e); } else if (rec.type === 'DAILY') { const from = new Date(Math.max(start.getTime(), Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate()))); const to = new Date(Math.min(end.getTime(), Date.UTC(evEnd.getUTCFullYear(), evEnd.getUTCMonth(), evEnd.getUTCDate()))); let cursor = new Date(from.getTime()); while (cursor <= to) { const diffDays = Math.floor((cursor.getTime() - Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate()))/(24*3600*1000)); if (diffDays % rec.interval === 0) { const occStartIso = setTimeFromIso(cursor, e.start); const occRemindIso = setTimeFromIso(cursor, e.remindAt || e.start); out.push({ ...e, start: occStartIso, remindAt: occRemindIso }); } cursor = new Date(cursor.getTime() + 24*3600*1000); // Safety break if (out.length > 5000) { console.warn('Excessive recurrence detected, truncating.', e); break; } } } else if (rec.type === 'WEEKLY') { const from = new Date(Math.max(start.getTime(), Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate()))); const to = new Date(Math.min(end.getTime(), Date.UTC(evEnd.getUTCFullYear(), evEnd.getUTCMonth(), evEnd.getUTCDate()))); const by = rec.by.length ? rec.by : [((new Date(e.start)).getDay() || 7)]; let cursor = new Date(from.getTime()); while (cursor <= to) { const weekday = cursor.getUTCDay() === 0 ? 7 : cursor.getUTCDay(); const weeksSinceStart = Math.floor((cursor.getTime() - Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate()))/(7*24*3600*1000)); if (by.includes(weekday) && (weeksSinceStart % rec.interval === 0)) { const occStartIso = setTimeFromIso(cursor, e.start); const occRemindIso = setTimeFromIso(cursor, e.remindAt || e.start); out.push({ ...e, start: occStartIso, remindAt: occRemindIso }); } cursor = new Date(cursor.getTime() + 24*3600*1000); // Safety break if (out.length > 5000) { console.warn('Excessive recurrence detected, truncating.', e); break; } } } }); return out; } function computeUpcomingReminders(e, sinceMs, untilMs) { const rec = parseRecurrenceStr(e.recurrence); const evStart = new Date(e.start); const evEnd = e.end ? new Date(e.end) : new Date(evStart.getTime() + 60*60*1000); const reminders = []; if (rec.type === 'NONE') { const iso = e.remindAt || e.start; if (!iso) return reminders; const t = new Date(iso).getTime(); if (t >= sinceMs && t <= untilMs) reminders.push(iso); return reminders; } const startDayMs = Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate()); const endDayMs = Date.UTC(evEnd.getUTCFullYear(), evEnd.getUTCMonth(), evEnd.getUTCDate()); const windowStartDayMs = Date.UTC(new Date(sinceMs).getUTCFullYear(), new Date(sinceMs).getUTCMonth(), new Date(sinceMs).getUTCDate()); const windowEndDayMs = Date.UTC(new Date(untilMs).getUTCFullYear(), new Date(untilMs).getUTCMonth(), new Date(untilMs).getUTCDate()); const fromDay = Math.max(startDayMs, windowStartDayMs); const toDay = Math.min(endDayMs, windowEndDayMs); const timeSourceIso = e.remindAt || e.start; for (let d = fromDay; d <= toDay; d += 24*3600*1000) { const cursor = new Date(d); if (rec.type === 'DAILY') { const diffDays = Math.floor((d - startDayMs)/(24*3600*1000)); if (diffDays % rec.interval === 0) { const iso = setTimeFromIso(cursor, timeSourceIso); const t = new Date(iso).getTime(); if (t >= sinceMs && t <= untilMs) reminders.push(iso); } } else if (rec.type === 'WEEKLY') { const weekday = cursor.getUTCDay() === 0 ? 7 : cursor.getUTCDay(); const weeksSinceStart = Math.floor((d - startDayMs)/(7*24*3600*1000)); const by = rec.by.length ? rec.by : [((new Date(e.start)).getDay() || 7)]; if (by.includes(weekday) && (weeksSinceStart % rec.interval === 0)) { const iso = setTimeFromIso(cursor, timeSourceIso); const t = new Date(iso).getTime(); if (t >= sinceMs && t <= untilMs) reminders.push(iso); } } } return reminders; } async function prefetchAdjacentMonths(year, month) { try { const prevY = month === 1 ? year - 1 : year; const prevM = month === 1 ? 12 : month - 1; const nextY = month === 12 ? year + 1 : year; const nextM = month === 12 ? 1 : month + 1; const pk = `${prevY}-${prevM}`; const nk = `${nextY}-${nextM}`; if (!monthCache.has(pk)) { SyncAPI.listByMonth(prevY, prevM).then(d => monthCache.set(pk, d)).catch(() => {}); } if (!monthCache.has(nk)) { SyncAPI.listByMonth(nextY, nextM).then(d => monthCache.set(nk, d)).catch(() => {}); } } catch (_) {} }