VulpesOS/gaia/apps/calendar/js/day_observer.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

384 lines
11 KiB
JavaScript
Executable file

define(function(require, exports) {
'use strict';
// Listen for changes on all busytimes inside a given day
// ---
// This module will listen for changes on any busytime inside a day and
// group/batch multiple add/remove events into a single emit call; making it
// easier to rerender all the busytimes at once and allowing us to use the
// same method to add/remove busytimes from the DOM, drastically simplifying
// the logic required.
// ---
// On the most common scenario there should not be that many busytimes inside
// the same day, and every time we add a new busytime to the DOM we need to
// check for "overlaps", so doing the full rerender will make a lot of sense
// and should not affect performance that much.
// ---
// It's way simpler to use a day-based logic for the whole calendar front-end,
// instead of knowing how to handle months/weeks.
var Calc = require('common/calc');
var EventEmitter2 = require('ext/eventemitter2');
var binsearch = require('common/binsearch');
var compare = require('common/compare');
var core = require('core');
var daysBetween = Calc.daysBetween;
var debounce = require('utils/mout').debounce;
var getDayId = Calc.getDayId;
var isAllDay = Calc.isAllDay;
var spanOfMonth = Calc.spanOfMonth;
// make sure we only trigger a single emit for multiple consecutive changes
var DISPATCH_DELAY = 25;
// maximum amount of months to keep in the cache
var MAX_CACHED_MONTHS = 5;
// stores busytimes by Id
// {key:id, value:Busytime}
var busytimes = new Map();
// stores events by Id
// {key:id, value:Event}
var events = new Map();
// relationships between event Ids and busytimes Ids
// {key:eventId, value:Array<busytimeId>}
var eventsToBusytimes = new Map();
// cache a reference to all busytime+event data for the days based on the dayId
// {key:dayId, value:DayRecords}
var cache = new Map();
// stores the days IDs that changed since last dispatch
var dayQueue = new Set();
// stores the timespans for all the cached months
var cachedSpans = [];
// we lock the cache pruning during sync
var cacheLocked = false;
// emitter exposed because of unit tests
var emitter = exports.emitter = new EventEmitter2();
exports.init = function() {
// both "change" and "add" operations triggers a "persist" event
var eventStore = core.storeFactory.get('Event');
eventStore.on('persist', (id, event) => cacheEvent(event));
eventStore.on('remove', removeEventById);
var busytimeStore = core.storeFactory.get('Busytime');
busytimeStore.on('persist', (id, busy) => cacheBusytime(busy));
busytimeStore.on('remove', removeBusytimeById);
core.syncController.on('syncStart', () => {
cacheLocked = true;
});
core.syncController.on('syncComplete', () => {
cacheLocked = false;
pruneCache();
dispatch();
});
var calendarStore = core.storeFactory.get('Calendar');
calendarStore.on('calendarVisibilityChange', (id, calendar) => {
var type = calendar.localDisplayed ? 'add' : 'remove';
busytimes.forEach((busy, busyId) => {
if (busy.calendarId === id) {
registerBusytimeChange(busyId, type);
}
});
});
core.timeController.on('monthChange', loadMonth);
// make sure loadMonth is called during setup if 'monthChange' was dispatched
// before we added the listener
var month = core.timeController.month;
if (month) {
loadMonth(month);
}
};
exports.on = function(date, callback) {
var dayId = getDayId(date);
// important to trigger the callback to avoid getting into weird states when
// busytimes are loaded while views are not listening for changes
callback(getDay(dayId, date));
emitter.on(dayId, callback);
};
function getDay(dayId, date) {
if (cache.has(dayId)) {
return cache.get(dayId);
}
var day = {
dayId: dayId,
date: date,
amount: 0,
basic: [],
allday: []
};
cache.set(dayId, day);
return day;
}
exports.off = function(date, callback) {
emitter.off(getDayId(date), callback);
};
exports.removeAllListeners = function() {
emitter.removeAllListeners();
};
// findAssociated is not on the Event/Busytime store so that it can get the
// cache data and show the event details faster on the most common cases
exports.findAssociated = function(busytimeId) {
return queryBusytime(busytimeId).then(busytime => {
return queryEvent(busytime.eventId).then(event => {
return {
busytime: busytime,
event: event
};
});
});
};
function queryBusytime(busytimeId) {
if (busytimes.has(busytimeId)) {
return Promise.resolve(busytimes.get(busytimeId));
}
var busytimeStore = core.storeFactory.get('Busytime');
return busytimeStore.get(busytimeId);
}
function queryEvent(eventId) {
if (events.has(eventId)) {
return Promise.resolve(events.get(eventId));
}
var eventStore = core.storeFactory.get('Event');
return eventStore.get(eventId);
}
function cacheBusytime(busy) {
var {_id, startDate, endDate, eventId} = busy;
if (outsideSpans(startDate) && outsideSpans(endDate)) {
// ignore busytimes outside the cached spans
return;
}
busytimes.set(_id, busy);
eventsToBusytimes.get(eventId).push(_id);
registerBusytimeChange(_id);
}
function removeBusytimeById(id) {
var busy = busytimes.get(id);
if (!busy) {
// when removing all the data from the calendar the busytime might not be
// on the cache but still emit a "remove" event
return;
}
var eventId = busy.eventId;
var ids = eventsToBusytimes.get(eventId).filter(i => i !== id);
eventsToBusytimes.set(eventId, ids);
removeEventIfNoBusytimes(ids, eventId);
registerBusytimeChange(id, 'remove');
busytimes.delete(id);
}
function cacheEvent(event) {
var id = event._id;
events.set(id, event);
if (!eventsToBusytimes.has(id)) {
eventsToBusytimes.set(id, []);
}
}
function removeEventById(id) {
events.delete(id);
// busytimeStore will emit a 'remove' event for each busytime, so no need to
// handle it here; we simply remove the "join table"
eventsToBusytimes.delete(id);
}
// every time an event/calendar/busytime changes we need to trigger a change
// event and notify all the views that are listening to that specific date
function registerBusytimeChange(id, type) {
var busy = busytimes.get(id);
var {startDate, endDate} = busy;
// subtract 1 millisecond because allday events ends at 00:00:00 of next day,
// which would include one day more than expected for daysBetween
var end = new Date(endDate.getTime() - 1);
var calendarStore = core.storeFactory.get('Calendar');
// events from hidden calendars should not be displayed
var isRemove = type === 'remove' ||
!calendarStore.shouldDisplayCalendar(busy.calendarId);
daysBetween(startDate, end).forEach(date => {
if (outsideSpans(date)) {
// ignore dates outside the cached spans
return;
}
var dayId = getDayId(date);
if (isRemove && !cache.has(dayId)) {
return;
}
var day = getDay(dayId, date);
// it should always override the old data
day.basic = day.basic.filter(r => r.busytime._id !== id);
day.allday = day.allday.filter(r => r.busytime._id !== id);
if (!isRemove) {
var group = isAllDay(date, startDate, endDate) ?
day.allday :
day.basic;
sortedInsert(group, busy);
}
day.amount = day.basic.length + day.allday.length;
dayQueue.add(dayId);
});
dispatch();
}
function sortedInsert(group, busy) {
var index = binsearch.insert(group, busy.startDate, (date, record) => {
return compare(date, record.busytime.startDate);
});
var event = events.get(busy.eventId);
var calendarStore = core.storeFactory.get('Calendar');
group.splice(index, 0, {
event: event,
busytime: busy,
color: calendarStore.getColorByCalendarId(event.calendarId)
});
}
// debounce will make sure that we only trigger one "change" event per day even
// if we have multiple changes happening in a row
var dispatch = debounce(function() {
dayQueue.forEach(id => {
// need to remove ID from queue before dispatching event to avoid race
// conditions (eg. handler triggering a new queue+dispatch)
dayQueue.delete(id);
// dispatch is async so there is a small chance data isn't cached anymore
if (cache.has(id)) {
emitter.emit(id, cache.get(id));
}
});
}, DISPATCH_DELAY);
function loadMonth(newMonth) {
var span = spanOfMonth(newMonth);
// ensure we load the minimum amount of busytimes possible
var toLoad = span;
cachedSpans.every(cached => toLoad = cached.trimOverlap(toLoad));
if (!toLoad) {
// already loaded all the busytimes
return;
}
// cache the whole month instead of `toLoad` because we purge whole months
cachedSpans.push(span);
var busytimeStore = core.storeFactory.get('Busytime');
busytimeStore.loadSpan(toLoad, onBusytimeSpanLoad);
}
function onBusytimeSpanLoad(err, busytimes) {
if (err) {
console.error('Error loading Busytimes from TimeSpan:', err.toString());
return;
}
// remove duplicates and avoid loading events that are already cached
var eventIds = Array.from(new Set(
busytimes.map(b => b.eventId).filter(id => !events.has(id))
));
var eventStore = core.storeFactory.get('Event');
eventStore.findByIds(eventIds).then(events => {
// it's very important to cache the events before the busytimes otherwise
// the records won't contain the event data
Object.keys(events).forEach(key => cacheEvent(events[key]));
busytimes.forEach(cacheBusytime);
pruneCache();
});
}
function pruneCache() {
if (cacheLocked) {
return;
}
trimCachedSpans();
cache.forEach(removeDayFromCacheIfOutsideSpans);
eventsToBusytimes.forEach(removeEventIfNoBusytimes);
}
function trimCachedSpans() {
while (cachedSpans.length > MAX_CACHED_MONTHS) {
// since most changes are sequential, remove the timespans that are further
// away from the current month
var baseDate = core.timeController.month;
var maxDiff = 0;
var maxDiffIndex = 0;
cachedSpans.forEach((span, i) => {
var diff = Math.abs(span.start - baseDate);
if (diff > maxDiff) {
maxDiff = diff;
maxDiffIndex = i;
}
});
cachedSpans.splice(maxDiffIndex, 1);
}
}
function removeDayFromCacheIfOutsideSpans(day, id) {
if (outsideSpans(day.date)) {
day.basic.forEach(removeRecordIfOutsideSpans);
day.allday.forEach(removeRecordIfOutsideSpans);
cache.delete(id);
}
}
function outsideSpans(date) {
return !cachedSpans.some(timespan => timespan.contains(date));
}
function removeRecordIfOutsideSpans(record) {
removeBusytimeIfOutsideSpans(record.busytime);
}
function removeBusytimeIfOutsideSpans(busytime) {
var {_id, startDate, endDate} = busytime;
if (outsideSpans(startDate) && outsideSpans(endDate)) {
removeBusytimeById(_id);
}
}
function removeEventIfNoBusytimes(ids, eventId) {
if (!ids.length) {
removeEventById(eventId);
}
}
});