UBC course solver

opitimise choosing your courses

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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