Files

628 lines
26 KiB
JavaScript

// 主入口:初始化、交互、提醒与 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 (_) {}
}