UBC course solver

opitimise choosing your courses

/* 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();