feat: version 4.4.0 -- Calendario de reuniones
Menu Calendario (Mes/Semana/Dia/Agenda) para agendar reuniones con participantes internos y externos, RSVP, vinculo a proyecto, vencimientos de actividades, invitaciones iCalendar por correo, recurrencia con excepciones, recordatorios in-app y aviso de choques. Migracion 034_meetings.sql. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
|
||||
// DATETIME representa hora civil de America/Guayaquil (UTC-5 sin DST). Date en UTC se usa
|
||||
// solo para aritmética de calendario; no convierte estos valores a la zona del servidor.
|
||||
const parse = (s) => new Date(`${s.replace(' ', 'T')}Z`);
|
||||
const format = (d) => d.toISOString().slice(0, 19).replace('T', ' ');
|
||||
export const addMinutes = (s, n) => format(new Date(parse(s).getTime() + n * 60000));
|
||||
export const durationMinutes = (a, b) => (parse(b) - parse(a)) / 60000;
|
||||
export const guayaquilNow = () => format(new Date(Date.now() - 5 * 3600000));
|
||||
|
||||
function* occurrenceStarts(start, rule, max = 365) {
|
||||
const first = parse(start);
|
||||
if (!rule) { yield start; return; }
|
||||
let emitted = 0;
|
||||
const accept = (d) => d >= first && d.getUTCFullYear() <= 9999 && (!rule.until || format(d).slice(0, 10) <= rule.until);
|
||||
if (rule.frequency === 'daily') {
|
||||
for (let n = 0; emitted < max && (!rule.count || emitted < rule.count); n++) {
|
||||
const d = new Date(first.getTime() + n * rule.interval * 86400000);
|
||||
if (!accept(d)) break;
|
||||
emitted++; yield format(d);
|
||||
}
|
||||
} else if (rule.frequency === 'weekly') {
|
||||
const monday = new Date(first.getTime() - ((first.getUTCDay() + 6) % 7) * 86400000);
|
||||
for (let block = 0; emitted < max && (!rule.count || emitted < rule.count); block++) {
|
||||
const week = new Date(monday.getTime() + block * rule.interval * 7 * 86400000);
|
||||
if (week.getUTCFullYear() > 9999 || (rule.until && format(week).slice(0, 10) > rule.until)) break;
|
||||
for (const weekday of rule.weekdays) {
|
||||
const d = new Date(week.getTime() + weekday * 86400000);
|
||||
if (d < first) continue;
|
||||
if (!accept(d)) return;
|
||||
emitted++; yield format(d);
|
||||
if (emitted >= max || (rule.count && emitted >= rule.count)) return;
|
||||
}
|
||||
}
|
||||
} else if (rule.frequency === 'monthly') {
|
||||
const time = [first.getUTCHours(), first.getUTCMinutes(), first.getUTCSeconds()];
|
||||
for (let block = 0; emitted < max && (!rule.count || emitted < rule.count); block++) {
|
||||
const totalMonths = first.getUTCFullYear() * 12 + first.getUTCMonth() + block * rule.interval;
|
||||
const year = Math.floor(totalMonths / 12), month = totalMonths % 12;
|
||||
if (year > 9999 || (rule.until && `${year}-${String(month+1).padStart(2,'0')}-01` > rule.until)) break;
|
||||
const d = new Date(Date.UTC(year, month, first.getUTCDate(), ...time));
|
||||
if (d.getUTCMonth() !== month) continue; // p. ej. no hay 31 de febrero.
|
||||
if (!accept(d)) break;
|
||||
emitted++; yield format(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateRecurrence(raw, start) {
|
||||
if (!raw) return null;
|
||||
const { frequency, interval = 1, weekdays, until, count } = raw;
|
||||
if (!['daily', 'weekly', 'monthly'].includes(frequency) || !Number.isInteger(interval) || interval < 1 || interval > 365) throw new HttpError(400, 'Recurrencia invalida');
|
||||
if ((!!until) === (!!count)) throw new HttpError(400, 'Indica fecha final o numero de ocurrencias');
|
||||
if (count && (!Number.isInteger(count) || count < 1 || count > 365)) throw new HttpError(400, 'Maximo 365 ocurrencias');
|
||||
if (until && (typeof until !== 'string' || !/^\d{4}-\d\d-\d\d$/.test(until) || Number.isNaN(Date.parse(`${until}T00:00:00Z`)) || new Date(`${until}T00:00:00Z`).toISOString().slice(0,10) !== until || until < start.slice(0, 10))) throw new HttpError(400, 'Fecha final de recurrencia invalida');
|
||||
if (frequency === 'weekly' && (!Array.isArray(weekdays) || !weekdays.length || weekdays.some((v) => !Number.isInteger(v) || v < 0 || v > 6))) throw new HttpError(400, 'Dias semanales invalidos');
|
||||
if (frequency === 'weekly' && !weekdays.includes((parse(start).getUTCDay() + 6) % 7)) throw new HttpError(400, 'El primer dia debe estar incluido');
|
||||
const rule = { frequency, interval, weekdays: frequency === 'weekly' ? [...new Set(weekdays)].sort() : [], until: until || null, count: count || null };
|
||||
const occurrences = [...occurrenceStarts(start, rule, 366)];
|
||||
if (occurrences.length > 365) throw new HttpError(400, 'Maximo 365 ocurrencias');
|
||||
if (count && occurrences.length < count) throw new HttpError(400, 'La serie supera el rango de fechas admitido');
|
||||
return rule;
|
||||
}
|
||||
|
||||
export function expand(meeting, from, to) {
|
||||
if (meeting.cancelled_at) return [];
|
||||
const rule = meeting.recurrence_json ? JSON.parse(meeting.recurrence_json) : null;
|
||||
const duration = durationMinutes(meeting.starts_at, meeting.ends_at);
|
||||
const exclusions = new Set(meeting.exceptions || []);
|
||||
const out = [];
|
||||
for (const start of occurrenceStarts(meeting.starts_at, rule)) {
|
||||
if (start >= to) break;
|
||||
const end = addMinutes(start, duration);
|
||||
if (end > from && !exclusions.has(start)) out.push({ ...meeting, occurrence_start: start, occurrence_end: end });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user