diff --git a/front/assets/src/css/base.css b/front/assets/src/css/base.css index bbcb515..c8d24b3 100644 --- a/front/assets/src/css/base.css +++ b/front/assets/src/css/base.css @@ -45,3 +45,12 @@ input[type=number] { /*when an element is selected and pointer re-enters the rating container, selected rate and siblings get semi transparent, as reminder of current selection*/ .rating:hover > input:checked ~ label:before{ opacity: 0.4; } + +#ucalendar { + background: linear-gradient(140deg, #72dcdc 0%, #52abc6 100%); + transition: all 0.3s ease-in-out; + font-weight: bolder; + border: none; + color: white; + text-shadow: 0 0 1px #0c5564; +} diff --git a/front/assets/src/planner.js b/front/assets/src/planner.js index cd62f59..924784f 100644 --- a/front/assets/src/planner.js +++ b/front/assets/src/planner.js @@ -12,6 +12,7 @@ import { add, remove, loadRamo, loadFromCookie } from "./utils/schedule" import { loadInfo } from "./utils/info" import { search } from "./utils/search" import { loadQuota } from "./utils/quota" +import * as ucalendar from "./utils/ucalendar" export { toggle, toggleRow, toggleDay, clearSelects, @@ -30,6 +31,8 @@ $(() => { // Load ramos from cookie wp.loadFromCookie() + $("#ucalendar").on("click", () => ucalendar.dowload_schedule()) + // Load filters dropdowns $("#campus").multipleSelect({ selectAll: false, showClear: true }) $("#formato").multipleSelect({ selectAll: false, showClear: true }) diff --git a/front/assets/src/utils/schedule.js b/front/assets/src/utils/schedule.js index 7c1845f..aa069ec 100644 --- a/front/assets/src/utils/schedule.js +++ b/front/assets/src/utils/schedule.js @@ -1,5 +1,6 @@ import { getCookie, setCookie } from "./cookies" import ga_event from "./ga_event.js" +import * as ucalendar from "./ucalendar" // Add ramo to cookie and load it to schedule @@ -25,6 +26,9 @@ const remove = (id) => { if (index != -1) { saved.splice(index, 1) } + + ucalendar.remove_from_schedule_page(id) + setCookie("ramos", saved.join(","), 120) var parents = $(`td > [name='ramo_${id}']`).parent().get() @@ -64,6 +68,9 @@ const toCourseClassName = (type_of_course) => { const loadRamoHandleResponse = (response, id, showDelete, ramos) => { var ramo = response + + ucalendar.add_from_schedule_page(id, ramo) + // Print in horario for (let [key, value] of Object.entries(ramo.schedule)) { var slot = $(`#${key.toUpperCase()}`) diff --git a/front/assets/src/utils/ucalendar/consts.js b/front/assets/src/utils/ucalendar/consts.js new file mode 100644 index 0000000..35f455c --- /dev/null +++ b/front/assets/src/utils/ucalendar/consts.js @@ -0,0 +1,59 @@ +export const every_year_holidays = [ + "05-01", // Día del trabajo + "05-21", // Días de las Glorias Navales + "08-15", // Asunción de la Virgen + "09-18", // Primera Junta Nacional de Gobierno + "09-19", // Glorias del Ejército + "10-11", // Celebración del Día del Encuentro de Dos Mundos + "10-31", // Día de las Iglesias Evangélicas y Protestantes + "11-01", // Día de Todos los Santos + "12-08", // Inmaculada Concepción de la Virgen + "12-25", // Navidad +] + +export const period_range = { + "2023-1": [new Date(2023, 2, 6), new Date(2023, 5, 30)], + "2023-2": [new Date(2023, 7, 7), new Date(2023, 10, 1)], +} + + +export const holidays = [ + // Semana Santa + new Date(2023, 3, 6), + new Date(2023, 3, 7), + new Date(2023, 3, 8), + new Date(2023, 3, 9), + // Receso + new Date(2023, 4, 2), + new Date(2023, 4, 3), + new Date(2023, 4, 4), + new Date(2023, 4, 5), + new Date(2023, 4, 6), + // San Pedro y San Pablo + new Date(2023, 5, 26), + // Asunción de la Virgen + new Date(2023, 7, 14), + // Receso + new Date(2023, 9, 2), + new Date(2023, 9, 3), + new Date(2023, 9, 4), + new Date(2023, 9, 5), + new Date(2023, 9, 6), + new Date(2023, 9, 7), +] + +export const module_length = { hours: 1, minutes: 20 } + +export const module_start_time = { + 1: { hour: 8, minute: 30 }, + 2: { hour: 10, minute: 0 }, + 3: { hour: 11, minute: 30 }, + 4: { hour: 14, minute: 0 }, + 5: { hour: 15, minute: 30 }, + 6: { hour: 17, minute: 0 }, + 7: { hour: 18, minute: 30 }, + 8: { hour: 20, minute: 0 }, +} + +export const ics_day_names = { l: "MO", m: "TU", w: "WE", j: "TH", v: "FR", s: "SA", d: "SU" } +export const days = [...Object.keys(ics_day_names)] diff --git a/front/assets/src/utils/ucalendar/index.js b/front/assets/src/utils/ucalendar/index.js new file mode 100644 index 0000000..e20a1ad --- /dev/null +++ b/front/assets/src/utils/ucalendar/index.js @@ -0,0 +1,125 @@ +/** @author @benjavicente */ + +/** @typedef {{[key: string]: string}} ScheduleMap */ +/** @typedef {{ id: number, initials: string, name: string, period: string, schedule: ScheduleMap }} Course */ +/** @typedef {{ day: string, module: number, type: string, group: ScheduleElement[] }} ScheduleElement */ +/** @typedef {{ days: string[], modules: number[], type: string }} ScheduleBlock */ + + +import { module_start_time, holidays, every_year_holidays, module_length, ics_day_names, days, period_range } from "./consts" +import { calendarTemplate, eventTemplate, exDateTemplate } from "./templates" +import { dispatchDownload, eqSet, firstIndex } from "./utils" + + +// API + +/** @type {Course[]} */ +let courses = [] +export const add_from_schedule_page = (id, course) => courses.push({ id, ...course }) +export const remove_from_schedule_page = (id) => courses = courses.filter((c) => c.id !== id) +export const dowload_schedule = () => dispatchDownload(makeCalendarFromCoruses(courses), "horario.ics") + + +// UCalendar + +/** @param {Course[]} courses */ +function makeCalendarFromCoruses(courses, semester = "2023-1") { + return calendarTemplate(courses.flatMap(groupModules).map((e) => toEvent(e, semester)).map(eventTemplate).join("\n")) +} + +/** @param {Date} dateToChange, @param {Date} dateWithTime */ +function changeTimeOfDate(dateToChange, dateWithTime) { + dateToChange.setHours(dateWithTime.getHours(), dateWithTime.getMinutes(), dateWithTime.getSeconds()) + return dateToChange +} + +/** @param {Date} start */ +function generateExDates(start) { + const ex_dates = holidays.map((h) => new Date(h)) // copy dates + for (const [month, day] of every_year_holidays.map(h => h.split("-"))) + ex_dates.push(new Date(2023, month - 1, + day)) + return ex_dates.map(d => changeTimeOfDate(d, start)).map(exDateTemplate).join("\n") +} + +/** @param {Course} course */ +function groupModules({ schedule, ...course }) { + + const scheduleDays = Object.entries(schedule).map(([[day, m], type]) => { + /** @type {ScheduleElement} */ + const self = { day, module: parseInt(m), type, group: [] } + self.group.push(self) + return self + }) + + // Vertical + for (const bs of scheduleDays) { + for (const rs of scheduleDays) { + if (bs.group === rs.group || bs.type !== rs.type) continue + + // Ve si están juntos y en el mismo día + if (bs.day !== rs.day) continue + if (Math.abs(bs.module - rs.module) > 1) continue + + // No se puede expandir entre 3 y 4 (almuerzo) + if (bs.module === 3 && rs.module === 4) continue + if (bs.module === 4 && rs.module === 3) continue + + bs.group.push(rs) + rs.group = bs.group + } + } + + // Horizontal + for (const bs of scheduleDays) { + for (const rs of scheduleDays) { + if (bs.group === rs.group || bs.type !== rs.type) continue + + // Ver si los grupos tienen los mismos modulos + const bsModSet = new Set(bs.group.map((e) => e.module)) + const rsModSet = new Set(rs.group.map((e) => e.module)) + if (!eqSet(bsModSet, rsModSet)) continue + + bs.group.push(rs) + rs.group = bs.group + } + } + + // Entregar grupos + return [...new Set(scheduleDays.map((g) => g.group))].map((group) => { + const days = [... new Set(group.map((g) => g.day))] + const modules = [... new Set(group.map((g) => g.module))] + const type = group[0].type + return { days, modules, type, group, course } + }) +} + +/** @param {ScheduleBlock} block, @param {string} semester */ +function toEvent(block, semester) { + const first_module = Math.min(...block.modules) + const last_module = Math.max(...block.modules) + const first_day_number = firstIndex(days, block.days) + + const [initial_date, last_date] = period_range[semester] + + // Se necesita mover el primer dia de clases para que este en el primer día del bloque + // en específico, así podemos decir que el evento se repite de ahi hasta el ultimo dia + const initial_date_day = new Date(initial_date).getDay() // 0: Domingo, 1: Lunes, ... + const initial_date_offset = ((first_day_number + 1) - initial_date_day + 7) % 7 + const initial_date_offsetted = new Date(initial_date) + initial_date_offsetted.setDate(initial_date.getDate() + initial_date_offset) + + const { hour, minute } = module_start_time[first_module] + const start = new Date(initial_date_offsetted) + start.setHours(hour, minute, 0, 0) // Parte en el minuto y segundo 0 + + const end_delta = module_start_time[last_module] + const end = new Date(start) + end.setHours(end_delta.hour + module_length.hours, end_delta.minute + module_length.minutes) + + const block_days = block.days.map((d) => ics_day_names[d]).join(",") + + const summary = `${block.type === "CLAS" ? "" : `${block.type} `}${block.course.name}` + const ex_dates = generateExDates(start) + + return { start, end, last_date, block_days, summary, ex_dates } +} diff --git a/front/assets/src/utils/ucalendar/templates.js b/front/assets/src/utils/ucalendar/templates.js new file mode 100644 index 0000000..c0e3faa --- /dev/null +++ b/front/assets/src/utils/ucalendar/templates.js @@ -0,0 +1,53 @@ +const toICSDatetime = (date) => { + const year = date.getFullYear() + const month = `0${date.getMonth() + 1}`.slice(-2) + const day = `0${date.getDate()}`.slice(-2) + const hours = `0${date.getHours()}`.slice(-2) + const minutes = `0${date.getMinutes()}`.slice(-2) + const seconds = `0${date.getSeconds()}`.slice(-2) + return `${year}${month}${day}T${hours}${minutes}${seconds}` +} + + +export const calendarTemplate = (calendar) => ` +BEGIN:VCALENDAR +PRODID:-//remos-uc//ucalendar//CL +VERSION:2.0 +CALSCALE:GREGORIAN +X-WR-TIMEZONE:America/Santiago +BEGIN:VTIMEZONE +TZID:America/Santiago +X-LIC-LOCATION:America/Santiago +BEGIN:STANDARD +TZOFFSETFROM:-0300 +TZOFFSETTO:-0400 +TZNAME:-04 +DTSTART:19700405T000000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU +END:STANDARD +BEGIN:DAYLIGHT +TZOFFSETFROM:-0400 +TZOFFSETTO:-0300 +TZNAME:-03 +RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=1SU +END:DAYLIGHT +END:VTIMEZONE +${calendar} +END:VCALENDAR +` + +let event_id = 0 +export const eventTemplate = (e) => ` +BEGIN:VEVENT +DTSTART;TZID=America/Santiago:${toICSDatetime(e.start)} +DTEND;TZID=America/Santiago:${toICSDatetime(e.end)} +RRULE:FREQ=WEEKLY;UNTIL=${toICSDatetime(e.last_date)};BYDAY=${e.block_days} +${e.ex_dates} +DTSTAMP:${toICSDatetime(new Date())} +UID:${event_id++} +DESCRIPTION:${e.description || ""} +SUMMARY:${e.summary} +END:VEVENT +` + +export const exDateTemplate = (date) => `EXDATE;TZID=America/Santiago:${toICSDatetime(date)}` diff --git a/front/assets/src/utils/ucalendar/utils.js b/front/assets/src/utils/ucalendar/utils.js new file mode 100644 index 0000000..12010ab --- /dev/null +++ b/front/assets/src/utils/ucalendar/utils.js @@ -0,0 +1,15 @@ +export const eqSet = (a, b) => a.size === b.size && [...a].every(value => b.has(value)) + +export function firstIndex(array_to_search, values) { + for (const value of array_to_search) if (values.includes(value)) return array_to_search.indexOf(value) +} + +export function dispatchDownload(content, name = "horario.ics") { + const e = document.createElement("a") + e.setAttribute("href", `data:text/plain;charset=utf-8,${encodeURIComponent(content)}`) + e.setAttribute("download", name) + e.style.display = "none" + document.body.appendChild(e) + e.click() + document.body.removeChild(e) +} diff --git a/front/templates/courses/planner.html b/front/templates/courses/planner.html index 921caa9..f2e7a93 100644 --- a/front/templates/courses/planner.html +++ b/front/templates/courses/planner.html @@ -77,6 +77,7 @@