feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
// 主入口:初始化、交互、提醒与 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 (_) {}
|
||||
}
|
||||
Reference in New Issue
Block a user