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:
Milton Gonzalez
2026-10-28 11:04:41 -05:00
parent a6cc41666e
commit 6066f27ba0
34 changed files with 986 additions and 16 deletions
@@ -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;
}