UBC course solver

opitimise choosing your courses

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

/* eslint-disable camelcase */
/* eslint-disable no-use-before-define */
/* eslint-disable max-len */
// ==UserScript==
// @name         UBC course solver
// @namespace    https://courses.students.ubc.ca/
// @version      1.0
// @description  opitimise choosing your courses
// @author       kemkemG0
// @match        https://courses.students.ubc.ca/cs/courseschedule?pname=subjarea&tname=sectsearch*
// @match        https://courses.students.ubc.ca/cs/courseschedule
// @icon         https://www.google.com/s2/favicons?sz=64&domain=stackoverflow.com
// @grant        none
 // @license MIT
// ==/UserScript==

const GROUPED_DATA_KEY = 'groupedData';
const WEEK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const G_results = [];
let G_mxNum = 0;
let errorMsg = '';

const getAddedCoursesRow = () => {
  const rows = $('table.section-summary tbody tr');
  return [...Array(rows.length)]
    .map((_, ind) => {
      const row = rows.eq(ind).children();
      return {
        groupName: row.find("input[type='text']").val(),
        courseName: row.eq(2).text(),
        term: Number(row.eq(4).text()),
        days: [0, 1, 2, 3, 4, 5, 6].filter((__, i) => row.eq(7).text().includes(WEEK_DAYS[i])),
        start: row.eq(8).text().split(':').join(''),
        end: row.eq(9).text().split(':').join(''),
      };
    })
    .filter((val) => val.groupName !== '');
};

const getItem = () => {
  const item = localStorage.getItem(GROUPED_DATA_KEY);
  if (item === null || item === '') return {};
  const data = JSON.parse(item);
  Object.keys(data).forEach((group) => {
    data[group].forEach((course, ind) => {
      const {
        start, end, term, ...others
      } = course;
      data[group][ind] = {
        ...others, term: parseInt(term, 10), start: parseInt(start, 10), end: parseInt(end, 10),
      };
    });
  });
  return data;
};

const setItem = (data) => {
  const copied = { ...data };
  Object.keys(data).forEach((groupName) => {
    if (data[groupName].length === 0) delete copied[groupName];
  });
  localStorage.setItem(GROUPED_DATA_KEY, JSON.stringify(copied));
};

const deleteCourse = (id) => {
  // delete this from localstorage and rerender "Chosen Courses"
  const newId = id.replace('delete-', 'selected-');
  const groupName = $(`#${newId}`).parents('div')[0].id.replace('group-', '');
  const storedData = getItem();
  storedData[groupName] = storedData[groupName].filter((course) => course.courseName.replaceAll(' ', '') !== id.replace('delete-', ''));
  setItem(storedData);
  renderChosenCourses();
};

const onUpdate = () => {
  const savedData = getItem();
  // note: courseName is unique
  getAddedCoursesRow().forEach((newCourse) => {
    Object.keys(savedData).forEach((savedGroupName) => {
      savedData[savedGroupName] = savedData[savedGroupName].filter((savedCourse) => savedCourse.courseName !== newCourse.courseName);
    });
    if (savedData[newCourse.groupName] === undefined) savedData[newCourse.groupName] = [];
    const { groupName, ...others } = newCourse;
    savedData[groupName].push(others);
  });
  Object.keys(savedData).forEach((groupName) => {
    if (savedData[groupName].length === 0) delete savedData[groupName];
  });
  setItem(savedData);
  renderChosenCourses();
};

const onClear = () => {
  setItem({});
  render();
};

const onCreate = () => {
  // time is from 00:00 to 24:00
  // 0000 to 2460
  // ready array timetable[3][5][2460]:=[term][day][time]
  const test = [0, 1, 2].map(() => [...Array(5)].map(() => []));
  const savedData = getItem();
  const groupNameList = Object.keys(savedData);
  const tempSelected = {};
  let depth = 0;

  const canChoose = (lists, startEnd) => {
    const s = startEnd[0];
    const e = startEnd[1];
    let res = true;
    // (s,e,x,x) or (x,x,s,e) is ok
    // otherwise no
    lists.forEach((t) => {
      if (!((e <= t[0]) || (t[1] <= s))) { res = false; }
    });
    return res;
  };
  const dfs = (currentGroupInd = 0) => {
    depth += 1;
    if (currentGroupInd === groupNameList.length) {
      const res = Object.keys(tempSelected).map((key) => tempSelected[key]);
      G_mxNum = Math.max(G_mxNum, res.length);
      if (G_mxNum <= res.length)G_results.push(res);
      return;
    }

    if (depth >= 1000000) return;
    savedData[groupNameList[currentGroupInd]].forEach((course) => {
      const {
        start, end, term, courseName, days,
      } = course;
      // WITH using "course"
      // deciede which course to use
      // Euler Tour(modify => recursion => fix)
      // for stop dfs if not possible
      let isContinue = true;
      days.forEach((day) => {
        if (!canChoose(test[term][day], [start, end])) isContinue = false;
        if (!isContinue) return;
        test[term][day].push([start, end]);
      });
      if (!isContinue) return;

      tempSelected[courseName] = { ...course };
      dfs(currentGroupInd + 1);
      delete tempSelected[courseName];
      days.forEach((day) => {
        test[term][day].forEach((_, index) => {
          if (test[term][day][index][0] === start && test[term][day][index][1] === end) { test[term][day].splice(index, 1); }
        });
      });
      // WITHOUT using "course"
      dfs(currentGroupInd + 1);
    });
  };
  G_results.length = 0;
  dfs();
  const maxSelected = G_results.reduce((sum, e) => Math.max(sum, e.length), 0);
  const temp = [...G_results.filter((e) => e.length === maxSelected)];
  G_results.length = 0;
  G_results.push(...temp);

  if (G_results.length !== 0 && G_results[0].length !== groupNameList.length) errorMsg = 'Combination that allows you to take all types of classes was not found.<br>The combinations with the maximum number of classes will be displayed on the timetable.';
  else errorMsg = '';

  renderTable();
  renderScheduledCourses();
};

const getTimeTableNumber = () => parseInt($('#suggest-timetable').attr('data-combi'), 10);

const buttonsOnClickListener = () => {
  document.getElementById('group-name-update').addEventListener('click', onUpdate);
  document.getElementById('create-timetable').addEventListener('click', onCreate);
  document.getElementById('clear-chosen-courses').addEventListener('click', onClear);
  $('body').on('click', '.course-accordion-delete-button', (e) => { deleteCourse(e.target.id); });
  document.getElementById('prev-table').addEventListener('click', () => {
    const current = getTimeTableNumber();
    $('#suggest-timetable').attr('data-combi', Math.max(0, current - 1));

    renderTable();
    renderScheduledCourses();
  });
  document.getElementById('next-table').addEventListener('click', () => {
    const current = getTimeTableNumber();
    $('#suggest-timetable').attr('data-combi', Math.min(G_results.length - 1, current + 1));

    renderTable();
    renderScheduledCourses();
  });
};

/*
 * It goes : TERM-TIMESLOT-DAY (e.g. t1-2-0 ,t1-3-0, t1-2-2, t1-3-2, t1-2-4, t1-3-4 where
 * t1-2-0 would be be term 1 - 8:00  (0 is 700, 1 is 730) - Monday (0 is Monday, 1 is Tuesday, etc))
*/
const TIME_SLOT_LIST = [700, 730, 800, 830, 900, 930, 1000, 1030, 1100, 1130, 1200, 1230, 1300, 1330, 1400, 1430, 1500, 1530, 1600, 1630, 1700, 1730, 1800, 1830, 1900, 1930, 2000, 2030];
const time2timeSlot = (time) => TIME_SLOT_LIST.indexOf(time);

const createBaseTable = (term) => {
  let html = `<table><tbody">
    <tr>
      <td class="tt-header-mini">&nbsp;</td>
      <td class="tt-header-mini">Mon</td>
      <td class="tt-header-mini">Tue</td>
      <td class="tt-header-mini">Wed</td>
      <td class="tt-header-mini">Thu</td>
      <td class="tt-header-mini">Fri&nbsp;&nbsp;</td>
    </tr><tr></tr>`;
  TIME_SLOT_LIST.forEach((time, timeSlot) => {
    html += `<tr><td align="center" class="tt-header-mini">${time}</td>`;
    [0, 1, 2, 3, 4].forEach((day) => { html += `<td class="tt-notime-mini" id="new-t${term}-${timeSlot}-${day}">&nbsp;</td>`; });
    html += '</tr>';
  });
  html += '</tbody></table>';
  return html;
};

const clearTimeTable = () => {
  $('#comb-not-found').empty();
  $('#comb-not-found').append('');
  // change every class to notime
  TIME_SLOT_LIST.forEach((time, timeSlot) => {
    [1, 2].forEach((term) => {
      [0, 1, 2, 3, 4].forEach((day) => {
        const id = `new-t${term}-${timeSlot}-${day}`;
        $(`#${id}`).attr('class', 'tt-notime-mini');
      });
    });
  });
};

const editTimeTable = () => {
  const tableNum = getTimeTableNumber();
  const dynamicText = G_results.length !== 0 ? `${tableNum + 1}/${G_results.length}` : '';
  $('#time-table-title').text(`TimeTable  ${dynamicText}`);
  $('#comb-not-found').empty();
  $('#comb-not-found').append(errorMsg);
  const bgColors = ['jp-orange', 'jp-blue', 'jp-green', 'jp-pink', 'jp-purple', 'jp-gold'];
  G_results[tableNum]?.forEach((course, cind) => {
    course.days.forEach((day) => {
      TIME_SLOT_LIST.forEach((time) => {
        if (course.start <= time && time < course.end) {
          const id = `new-t${course.term}-${time2timeSlot(time)}-${day}`;
          $(`#${id}`).attr('class', `${bgColors[cind % 6]} selected-course-on-table`);
          $(`#${id}`).attr('data-hover', course.courseName);
        }
      });
    });
  });
};

const renderTable = () => {
  clearTimeTable();
  editTimeTable();
};

const renderScheduledCourses = () => {
  let html = '';
  $('#time-schedule-list').empty();
  if (G_results.length === 0) return;
  html += '<h4>Selected Courses</h4>';
  html += '<table class="table table-striped"><tbody>';
  G_results[getTimeTableNumber()]?.forEach((course, ind) => {
    html += `<tr class="section${(ind % 2) + 1}"><td>${course.courseName}<td></tr>`;
  });
  html += '</tbody></table>';
  $('#time-schedule-list').append(html);
};

const renderChosenCourses = () => {
  $('#chosen-courses').empty();
  const savedData = getItem();
  if (JSON.stringify(savedData) === '{}') return;
  Object.keys(savedData).forEach((groupName) => {
    const courseList = savedData[groupName];
    const createAccordion = () => {
      let res = `<details><summary><strong>${groupName} :      ${courseList.length} selected</strong></summary>`;
      res += '<ul>';
      courseList.forEach((course) => {
        res += `<li id="selected-${course.courseName.replaceAll(' ', '')}">${course.courseName} <input type="button" value="delete" class="course-accordion-delete-button btn-danger" id="delete-${course.courseName.replaceAll(' ', '')}" ></li>`;
      });
      res += '</ul>';
      res += '</details>';
      return res;
    };
    $('#chosen-courses').append(`
    <div id="group-${groupName}">
        ${createAccordion()}
        </details>
    </div>
`);
  });
};

const render = () => {
  renderChosenCourses();
  renderTable();
};

const addGlobalCSS = () => {
  const res = `
  <style>
  div #suggest-timetable table tr td table{
    font-size: 10px;
    line-height: 100%;
    border-collapse: separate !important;
    border-spacing: 2px !important;
    margin: 0;
    padding: 0;
  }
  #suggest-timetable h6, #chosen-courses-area h3 {
    margin: 0;
    padding: 0;
}

  .selected-course-on-table:before{
    content: attr(data-hover);
    visibility: hidden;
    opacity: 0;
    width: 140px;
    background-color: midnightblue;
    color: white;
    text-align: center;
    font-size:14px;
    padding: 2px 0;
    transition: opacity 0.3s ease-in-out;
    transform:translate(20px,-20px);
    position: absolute;
    z-index: 10;
  }
  .selected-course-on-table:hover:before {
    opacity: 1;
    visibility: visible;
  }
  
  .jp-orange{background:#ee827c}
  .jp-blue{background:#44617b}
  .jp-green{background:#00a497}
  .jp-pink{background:#bc64a4}
  .jp-purple{background:#745399}
  .jp-gold{background:#e6b422}

  </style>
  `;
  $('head').prepend(res);
};

const createElements = () => {
  addGlobalCSS();
  const INPUT_AREA = '<input type="text" style="max-width:80px; max-height:10px;"></input>';
  $('thead tr').prepend(`
      <th>
          <div><button type="button" class="btn btn-warning" id="group-name-update">&nbsp;&nbsp;Add&nbsp;&nbsp;</button></div>
          Group Name
      </th>`);
  $('table.section-summary tbody tr').prepend(`<td>${INPUT_AREA}</td>`);
  // create #chosen-courses-area
  $('form[name="sect_srch_criteria_simp_search"]').after(
    `
    <hr />
    <h3>Course Solver</h3>
    <div id="comb-not-found" style="color:red;margin:10px;"></div>
    <div style="display: flex; justify-content: center">
      <div style="margin: auto 0" id="chosen-courses-area">
        <h4 style="margin: 1px">Added Courses</h4>
        <div id="chosen-courses"></div>
        <div id="create-clear-buttons">
          <button type="button" class="btn btn-success" id="create-timetable">
            CREATE
          </button>
          <button type="button" class="btn btn-danger" id="clear-chosen-courses">
            CLEAR
          </button>
        </div>
      </div>
      <div id="suggest-timetable" style="display:flex" data-combi=0>
      <a style="display:block; font-size:20px; margin:20px;text-decoration: none;" href="javascript:void(0)" id="prev-table"><<</a>
        <table cellspacing="0" cellpadding="0" border="1">
          <tbody>
            <tr>
              <td class="tt-legend"><h6 id="time-table-title">Timetable</h6></td>
            </tr>
            <tr>
              <td>
                <table cellpadding="0" border="0" cellspacing="2">
                  <tbody>
                    <tr>
                      <td align="center"><h6>Term 1</h6></td>
                      <td align="center"><h6>Term 2</h6></td>
                    </tr>
                    <tr>
                      <td valign="top">
                        ${createBaseTable(1)}
                        <!---->
                      </td>
                      <td valign="top">
                        ${createBaseTable(2)}
                        <!---->
                      </td>
                    </tr>
                  </tbody>
                </table>
              </td>
            </tr>
          </tbody>
        </table>
        <a style="display:block; font-size:20px; margin:20px;text-decoration: none;" href="javascript:void(0)" id="next-table">>></a>
      </div>
      <div id="time-schedule-list" style="margin:auto 0"></div>
    </div>
    <hr />
      `,
  );
};

const main = async () => {
  createElements();
  render();
  buttonsOnClickListener();
};

main();