ahbeng/NUSMods

View on GitHub
packages/nusmoderator/src/academicCalendar.ts

Summary

Maintainability
B
5 hrs
Test Coverage
import { addWeeks, startOfWeek, isBefore, getYear } from 'date-fns';
import { WeekType, Semester, AcadYear, AcadWeek, AcadWeekInfo } from './types';

/* eslint-disable no-fallthrough, no-console */

/**
 * Returns a Date object of the first weekday of Week 0 of that academic year.
 * Assumes Week 0 begins on the first Monday of August.
 * @param acadYear the academic year. E.g. "18/19"
 * @return {Date} Start date of the academic year
 */
export function getAcadYearStartDate(acadYear: string): Date {
  const shortYear = acadYear.split('/')[0];
  const targetYear = 2000 + parseInt(shortYear, 10);
  const firstDateOfMonth = new Date(targetYear, 7, 1, 0, 0, 0);
  const nearestMonday = startOfWeek(firstDateOfMonth, { weekStartsOn: 1 });

  if (isBefore(nearestMonday, firstDateOfMonth)) {
    const firstMonday = addWeeks(nearestMonday, 1);
    return firstMonday;
  }
  // 1st Aug is already a Monday
  return nearestMonday;
}

// Constant variables.
const oneWeekDuration = 1000 * 60 * 60 * 24 * 7;
const sem1 = 'Semester 1';
const sem2 = 'Semester 2';
const special1 = 'Special Term I';
const special2 = 'Special Term II';

/**
 * Takes in a Date and returns an object of acad year and start date for that year.
 * @param  {Date} date
 * @return {Object} acadYearObject - { year: "15/16", startDate: Date }
 */
export function getAcadYear(date: Date): AcadYear {
  const shortYear = getYear(date) % 100;

  // A date like 8th August 2015 can either be in AY15/16 or AY16/17. We check
  // the date against the start of AY15/16 and return the correct one.
  const potentialAcadYear = `${shortYear}/${shortYear + 1}`;
  const potentialStartDate = getAcadYearStartDate(potentialAcadYear);

  const year = isBefore(date, potentialStartDate)
    ? `${shortYear - 1}/${shortYear}`
    : potentialAcadYear;

  return {
    year,
    startDate: getAcadYearStartDate(year),
  };
}

/**
 * Computes the current academic semester.
 * Expects a week number of a year.
 * @param  {number} acadWeekNumber
 * @return {string} semester - "Semester 1"
 * @example acadWeekNumber(3)
 */
export function getAcadSem(acadWeekNumber: number): Semester | null {
  const earliestSupportedWeek = 1;
  const lastWeekOfSem1 = 23;
  const lastWeekOfSem2 = 40;
  const lastWeekOfSpecialSem1 = 46;
  const lastWeekOfSpecialSem2 = 52;

  if (acadWeekNumber < earliestSupportedWeek) {
    console.warn(`[nusmoderator] Unsupported acadWeekNumber: ${acadWeekNumber}`);
    return null;
  }

  if (acadWeekNumber <= lastWeekOfSem1) return sem1;
  if (acadWeekNumber <= lastWeekOfSem2) return sem2;
  if (acadWeekNumber <= lastWeekOfSpecialSem1) return special1;
  if (acadWeekNumber <= lastWeekOfSpecialSem2) return special2;

  console.warn(`[nusmoderator] Unsupported acadWeekNumber: ${acadWeekNumber}`);
  return null;
}

/**
 * Computes the current academic week of the semester
 * Expects a week number of a semester.
 * @param  {number} acadWeekNumber
 * @return {string} semester - "Recess" | "Reading" | "Examination"
 * @example acadWeekNumber(3)
 */
export function getAcadWeekName(acadWeekNumber: number): AcadWeek | null {
  switch (acadWeekNumber) {
    case 7:
      return {
        weekType: 'Recess',
        weekNumber: null,
      };
    case 15:
      return {
        weekType: 'Reading',
        weekNumber: null,
      };
    case 16:
    case 17:
      return {
        weekType: 'Examination',
        weekNumber: acadWeekNumber - 15,
      };
    default: {
      let weekNumber = acadWeekNumber;
      if (weekNumber >= 8) {
        // For weeks after recess week
        weekNumber -= 1;
      }

      if (acadWeekNumber < 1 || acadWeekNumber > 17) {
        console.warn(`[nusmoderator] Unsupported acadWeekNumber as parameter: ${acadWeekNumber}`);
        return null;
      }

      return {
        weekType: 'Instructional',
        weekNumber,
      };
    }
  }
}

/**
 * Computes the current academic week and return in an object of acad date components
 * @param  {Date} date
 * @return {Object}
 * {
 *   year: "15/16",
 *   sem: 'Semester 1'|'Semester 2'|'Special Sem 1'|'Special Sem 2',
 *   type: 'Instructional'|'Reading'|'Examination'|'Recess'|'Vacation'|'Orientation',
 *   num: <weekNum>
 * }
 */
export function getAcadWeekInfo(date: Date): AcadWeekInfo {
  const currentAcad = getAcadYear(date);
  const acadYear = currentAcad.year;
  const acadYearStartDate = getAcadYearStartDate(acadYear);

  let acadWeekNumber = Math.ceil(
    (date.getTime() - acadYearStartDate.getTime() + 1) / oneWeekDuration,
  );
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  const semester = getAcadSem(acadWeekNumber)!;

  let weekType = null;
  let weekNumber = null;
  switch (semester) {
    case sem2: // Semester 2 starts 22 weeks after Week 1 of semester 1
      acadWeekNumber -= 22;
    case sem1:
      if (acadWeekNumber === 1) {
        weekType = 'Orientation';
        break;
      }
      if (acadWeekNumber > 18) {
        weekType = 'Vacation';
        weekNumber = acadWeekNumber - 18;
        break;
      }
      acadWeekNumber -= 1;
      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      const acadWeek = getAcadWeekName(acadWeekNumber)!;
      weekType = acadWeek.weekType;
      weekNumber = acadWeek.weekNumber;
      break;
    case special2: // Special Term II starts 6 weeks after Special Term I
      acadWeekNumber -= 6;
    case special1: // Special Term I starts on week 41 of the AY
      acadWeekNumber -= 40;
      weekType = 'Instructional';
      weekNumber = acadWeekNumber;
      break;
    default:
      if (acadWeekNumber === 53) {
        // This means it is the 53th week of the AY, and this week is Vacation.
        // This happens 5 times every 28 years.
        weekType = 'Vacation';
        weekNumber = null;
      }
      break;
  }

  return {
    year: acadYear,
    sem: semester,
    type: weekType as WeekType | null,
    num: weekNumber,
  };
}

/**
 * Get the first day of the exam week for the given semester
 * @param {string} year
 * @param {number} semester
 * @returns {Date}
 */
export function getExamWeek(year: string, semester: number): Date | null {
  const startDate = getAcadYearStartDate(year);

  if (!startDate) {
    console.warn(`[nusmoderator] Unsupported year: ${year}`);
    return null;
  }

  const examWeek: { [semester: number]: number } = {
    1: 16,
    2: 38,
    3: 45,
    4: 51,
  };

  const weeks = examWeek[semester];
  if (!weeks) {
    console.warn(`[nusmoderator] Unknown semester: ${semester}`);
    return null;
  }

  const d = new Date(startDate.valueOf());
  d.setDate(startDate.getDate() + weeks * 7);
  return d;
}

export default {
  getAcadYearStartDate,
  getAcadYear,
  getAcadSem,
  getAcadWeekName,
  getAcadWeekInfo,
  getExamWeek,
};