/**
* @module EventView/Parts/Guests/FreeBusy
*/
import { MelEnumerable } from '../../../classes/enum.js';
import {
DATE_FORMAT,
DATE_HOUR_FORMAT,
DATE_TIME_FORMAT,
} from '../../../constants/constants.dates.js';
import { ____JsHtml } from '../../../html/JsHtml/JsHtml.js';
import { MelHtml } from '../../../html/JsHtml/MelHtml.js';
import { GuestsPart } from './guestspart.js';
import { MAX_SLOT, ROLE_ATTENDEE_OPTIONNAL } from './parts.constants.js';
import { TimePartManager } from './timepart.js';
/**
* @typedef FreeBusyTimesData
* @property {string} email Email du participant
* @property {string} start Date de début
* @property {string} end Date de fin
* @property {number} interval Interval entre chaque créneaux horaires
* @property {string} slots Liste des différents crénaux avec leurs disponibilités
*/
/**
* Un jour en millisecondes
* @constant
* @type {number}
* @package
*/
const DAY_MS = 86400000;
/**
* Une heure en millisecondes
* @constant
* @type {number}
* @package
*/
const HOUR_MS = 3600000;
/**
* Gère les informations de disponibilité des invités
* @class
* @classdesc Gère les informations de disponibilité des invités, récupère les X premiers créneaux disponibles pour chaque invité
*/
export class FreeBusyGuests {
constructor() {
const allday = $('#edit-allday');
/**
* Interval de temps entre chaque créneaux
* @readonly
* @type {number}
*/
this.interval = allday.checked ? 1440 : 60 / (cal.settings.timeslots || 1);
Object.defineProperty(this, 'interval', {
value: this.interval,
writable: false,
configurable: false,
});
}
/**
* Récupère les 3 prochains crénaux disponibles en prenant en compte chaque invités.
* @async
* @param {*} event Evènement du plugin `calendar`
* @returns {Promise<Slot[]>}
*/
async load_freebusy_data(event) {
if (!FreeBusyGuests.can) return [];
const interval = this.interval;
const event_attendees = event.attendees;
const date2servertime = cal.date2ISO8601;
const fix_date = FreeBusyGuests.FixDate;
const clone_date = FreeBusyGuests.CloneDate;
let from = new Date();
from.setTime(event.start);
from.setHours(0);
from.setMinutes(0);
from.setSeconds(0);
from.setMilliseconds(0);
let start = new Date(from.getTime() - DAY_MS * 2); // start 2 days before event
fix_date(start);
let end = new Date(start.getTime() + DAY_MS * 14); // load min. 14 days
let valid_slots = [];
let promises = [];
// load free-busy information for every attendee
let domid, email, tmp;
for (var i = 0; i < event_attendees.length; i++) {
if ((email = event_attendees[i].email)) {
domid = String(email).replace(rcmail.identifier_expr, '');
tmp = $.ajax({
type: 'GET',
dataType: 'json',
url: rcmail.get_task_url('calendar&_action=freebusy-times'), // rcmail.url('freebusy-times'),
data: {
email,
interval,
start: date2servertime(clone_date(start, 1)),
end: date2servertime(clone_date(end, 2)),
_remote: 1,
},
success: function (data) {
// find attendee
var i,
attendee = null;
for (i = 0; i < event_attendees.length; i++) {
if (event_attendees[i].email == data.email) {
attendee = event_attendees[i];
break;
}
}
if (attendee.role !== ROLE_ATTENDEE_OPTIONNAL) {
let slots = new Slots(data);
valid_slots.push(slots);
}
},
});
promises.push(tmp);
tmp = null;
}
}
await Promise.allSettled(promises);
if (valid_slots.length > 0) {
let next;
let nexts = [];
for (
let index = MelEnumerable.from(valid_slots[0].slots)
.select((x, index) => {
return { start: x.start, index };
})
.where((x) => x.start >= moment(event.start))
.select((item) => item.index)
.first(),
len = MelEnumerable.from(valid_slots)
.select((x) => x.slots.length)
.min();
index < len;
++index
) {
if (nexts.length >= MAX_SLOT) break;
next = MelEnumerable.from(valid_slots).select((x) => x.slots[index]);
if (next.any() && !next.any((x) => !x.isFree || x._is_in_working_hour))
nexts.push(next.first());
}
valid_slots = nexts;
}
return valid_slots;
}
/**
* fix date if jumped over a DST change
* @static
* @param {Date} date
*/
static FixDate(date) {
if (date.getHours() == 23) date.setTime(date.getTime() + HOUR_MS);
else if (date.getHours() > 0) date.setHours(0);
}
/**
* clone the given date object and optionally adjust time
* @param {Date} date
* @param {1 | 2} adjust 1 => set time to 00:00, 2 => set time to 23:59
* @static
* @returns {Date}
*/
static CloneDate(date, adjust) {
var d = 'toDate' in date ? date.toDate() : new Date(date.getTime());
// set time to 00:00
if (adjust == 1) {
d.setHours(0);
d.setMinutes(0);
}
// set time to 23:59
else if (adjust == 2) {
d.setHours(23);
d.setMinutes(59);
}
return d;
}
/**
* Récupère les 3 premiers créneaux disponibles
* @param {external:moment} start
* @param {external:moment} end
* @param {*} attendees
* @returns {Promise<Slot[]>}
*/
static async Get(start, end, attendees) {
return await new FreeBusyGuests().load_freebusy_data({
start,
end,
attendees,
});
}
}
/**
* Si on peut effectuer une requête pour récupérer les créneaux disponibles ou non
* @static
* @type {boolean}
*/
FreeBusyGuests.can = true;
/**
* Contient les slots horaires d'un utilisateur.
* @class
* @classdesc Contient les slots horaires d'un utilisateur.
*/
export class Slots {
/**
*
* @param {FreeBusyTimesData} data Données récupérer par le serveur
*/
constructor(data) {
/**
* Email de l'utilisateur
* @type {string}
*/
this.email = data.email;
/**
* Date de début
* @type {string}
*/
this.start = data.start;
/**
* Date de fin
* @type {string}
*/
this.end = data.end;
/**
* Interval de temps entre chaque créneaux
* @type {number}
*/
this.interval = data.interval;
/**
* Liste des créneaux horaires avec leurs disponibilités
* @type {Slot[]}
*/
this.slots = [];
for (let index = 0, len = data.slots.length; index < len; ++index) {
const slot = data.slots[index];
this.slots.push(new Slot(this.start, this.interval, index, slot));
}
}
/**
* @deprecated
* @param {*} how_many
* @param {*} start_date_corrector
* @returns {Slot[]}
*/
getNextFreeSlots(how_many, start_date_corrector = null) {
let array = [];
if (this.slots.length > 0) {
array = MelEnumerable.from(this.slots);
if (start_date_corrector)
array = array.where((x) => x.start >= start_date_corrector);
array = array
.where((x) => x.isFree && !x._is_in_working_hour)
.take(how_many)
.toArray();
}
return array;
}
*[Symbol.iterator]() {
let slot;
let next_slot;
let current_slot;
for (let index = 0, len = this.slots.length; index < len; ++index) {
current_slot = this.slots[index];
next_slot = this.slots[index + 1];
if (!slot) {
if (this._is_same(current_slot, next_slot)) {
slot = current_slot;
} else yield current_slot;
} else {
if (!this._is_same(current_slot, next_slot)) {
slot = this._extend_slot(slot, current_slot);
yield slot;
slot = null;
}
}
}
}
_is_same(slot, next_slot) {
return (
next_slot &&
slot.end.format(DATE_TIME_FORMAT) ===
next_slot.start.format(DATE_TIME_FORMAT) &&
slot.state === next_slot.state
);
}
_extend_slot(slot, next_slot) {
return new Slot(
slot.start,
moment.duration(next_slot.end.diff(slot.start)).as('minutes'),
0,
slot.state,
);
}
serialize() {
return {
email: this.email,
start: this.start,
end: this.end,
interval: this.interval,
slots: MelEnumerable.from(this.slots)
.select((x) => x.serialize())
.toArray(),
};
}
static Deserialize(data) {
const tmp = JSON.parse(JSON.stringify(data.slots));
data.slots.length = 0;
let slots = new Slots(data);
slots.slots = MelEnumerable.from(tmp)
.select((x) => Slot.Deserialize(x))
.toArray();
return slots;
}
}
/**
* Représentation d'un créneau horraire
* @class
* @classdesc Représentation d'un créneau horraire avec l'information de sa disponibilitée ou non
*/
export class Slot {
/**
*
* @param {string | external:moment | date | number} start Date de début
* @param {number} interval Interval entre chaque créneau
* @param {number} index Index du créneau
* @param {string} state Etat du créneau
*/
constructor(start, interval, index, state) {
/**
* Date de début du créneaux
* @type {external:moment}
*/
this.start = moment(start).add(interval * index, 'm');
/**
* Date de fin du créneau
* @type {external:moment}
* @readonly
*/
this.end;
/**
* Si le créneau est libre ou non
* @type {boolean}
* @readonly
*/
this.isFree = false;
Object.defineProperty(this, 'isFree', {
value: [
Slot.STATES.free,
Slot.STATES.telework,
Slot.STATES.unknown,
].includes(state),
writable: false,
configurable: false,
});
Object.defineProperty(this, 'end', {
get: () => {
let end = moment(this.start).add(interval, 'm');
if (this.start.format(DATE_FORMAT) !== end.format(DATE_FORMAT)) {
end = moment(this.start).endOf('d');
}
return end;
},
});
/**
* Etat du créneau
* @type {Slot.STATES}
* @readonly
*/
this.state = null;
Object.defineProperty(this, 'state', {
get: () => state,
});
/**
* Si le créneau est en jour travaillé ou non
* @type {boolean}
* @readonly
*/
this._is_in_working_hour;
Object.defineProperty(this, '_is_in_working_hour', {
get: () =>
(!!cal.settings.work_start &&
!!cal.settings.work_end &&
!(
this.start >=
moment(this.start)
.startOf('d')
.add(cal.settings.work_start, 'h') &&
this.start <
moment(this.start).startOf('d').add(cal.settings.work_end, 'h')
)) ||
this.start.format('d') === '0' ||
this.start.format('d') === '6',
});
}
/**
* Génère le HTML du créneau
* @param {TimePartManager} timePart
* @returns {____JsHtml}
*/
generate(timePart) {
return this.start.format(DATE_FORMAT) === this.end.format(DATE_FORMAT)
? this._generate_same_date(timePart)
: this._generate_different_date(timePart);
}
/**
* Génère le HTML du créneau si la date de début et de fin sont les mêmes
* @private
* @param {TimePartManager} timePart
* @returns {____JsHtml}
*/
_generate_same_date(timePart) {
return MelHtml.start
.button({
class: 'slot',
type: 'button',
onclick: this._onclick.bind(this, timePart),
})
.div({ class: 'slot-date d-flex' })
.icon('calendar_month')
.end()
.span()
.text(this.start.format(DATE_FORMAT))
.end()
.end()
.div({ class: 'slot-date d-flex' })
.icon('schedule')
.end()
.span()
.text(
`${this.start.format(DATE_HOUR_FORMAT)} - ${this.end.format(DATE_HOUR_FORMAT)}`,
)
.end()
.end()
.end();
}
/**
* Génère le HTML du créneau si la date de début et de fin sont différentes
* @param {TimePartManager} timePart
* @returns {____JsHtml}
*/
_generate_different_date(timePart) {
return MelHtml.start
.button({
class: 'slot',
type: 'button',
onclick: this._onclick.bind(this, timePart),
})
.div({ class: 'slot-date d-flex' })
.icon('date_range')
.end()
.span()
.text(this.start.format(DATE_TIME_FORMAT))
.end()
.end()
.div({ class: 'slot-date d-flex' })
.icon('remove')
.end()
.span()
.text(this.end.format(DATE_TIME_FORMAT))
.end()
.end()
.end();
}
/**
* Action à effectuer lors du clique sur le créneau
* @param {TimePartManager} timePart
*/
_onclick(timePart) {
GuestsPart.can = false;
timePart.start._$fakeField
.val(this.start.format(DATE_HOUR_FORMAT))
.change();
timePart.end._$fakeField.val(this.end.format(DATE_HOUR_FORMAT)).change();
timePart._$start_date.val(this.start.format(DATE_FORMAT)).change();
timePart._$end_date.val(this.start.format(DATE_FORMAT)).change();
GuestsPart.can = true;
GuestsPart.INSTANCE.update_free_busy();
}
serialize() {
return {
start: this.start.format(DATE_TIME_FORMAT),
interval: moment.duration(this.end.diff(this.start)).as('minutes'),
state: this.state,
};
}
static Deserialize(data) {
return new Slot(
moment(data.start, DATE_TIME_FORMAT),
data.interval,
0,
data.state,
);
}
}
/**
* Liste des disponibilités
* @static
* @enum {string}
*/
Slot.STATES = {
unknown: '0',
free: '1',
busy: '2',
tentative: '3',
oof: '4',
telework: '5',
leave: '6',
};
/**
* @static
* @enum {string}
*/
Slot.TEXTES = {
[Slot.STATES.unknown]: 'Inconnu',
[Slot.STATES.free]: 'Disponible',
[Slot.STATES.busy]: 'Occupé',
[Slot.STATES.tentative]: 'Provisoire',
[Slot.STATES.oof]: 'Absent',
[Slot.STATES.telework]: 'Télétravail',
[Slot.STATES.leave]: 'Congé',
};
/**
* @static
* @enum {string}
*/
Slot.COLORS = {
[Slot.STATES.unknown]: 'grey',
[Slot.STATES.free]: '#1ac01a',
[Slot.STATES.busy]: 'red',
[Slot.STATES.tentative]: 'grey',
[Slot.STATES.oof]: '#e0a80c',
[Slot.STATES.telework]: '#0f9cfa',
[Slot.STATES.leave]: '#fa7900',
};