WikiEducationFoundation/WikiEduDashboard

View on GitHub
app/assets/javascripts/utils/course_date_utils.js

Summary

Maintainability
B
5 hrs
Test Coverage
D
69%
import { filter } from 'lodash-es';
import {
  isValid,
  format,
  parseISO,
  isAfter,
  differenceInMonths,
  startOfWeek,
  addDays,
  differenceInWeeks,
  endOfWeek,
  getDay,
  addWeeks,
  isBefore,
} from 'date-fns';
import { toDate } from './date_utils';

const CourseDateUtils = {
  isDateValid(date) {
    return /^20\d{2}-\d{2}-\d{2}/.test(date) && isValid(toDate(date));
  },

  /**
   * @param  {Date} datetime
   * @param  {boolean} showTime=false
   */
  formattedDateTime(datetime, showTime = false) {
    let timeZoneAbbr = '';
    let timeFormat = '';

    if (showTime) {
      timeFormat = ' HH:mm';
      try {
        timeZoneAbbr = ' ';
        timeZoneAbbr += datetime.toString().split('(')[1].slice(0, -1);
      } catch (err) {
        timeZoneAbbr = '';
      }
    }
    const dateFormat = `yyyy-MM-dd${timeFormat}`;
    return format(datetime, dateFormat) + timeZoneAbbr;
  },

  // Returns an object of minDate and maxDate props for each date field of a course
  dateProps(course) {
    const startDate = parseISO(course.start);
    const props = {
      end: {
        minDate: startDate
      },
      timeline_start: {
        minDate: startDate,
        maxDate: parseISO(course.timeline_end)
      },
      timeline_end: {
        minDate: parseISO(course.timeline_start),
        maxDate: parseISO(course.end)
      }
    };
    return props;
  },

  // This method takes a current version of a course and an updated key-value pair
  // for changing one of the date fields and returns a course where all the dates
  // are consistent with each other.
  updateCourseDates(prevCourse, valueKey, value) {
    const updatedCourse = Object.assign({}, prevCourse); // clone the course
    updatedCourse[valueKey] = value;
    // Just return with the new value if it doesn't pass validation
    // or if it it lacks timeline dates
    if (!this.isDateValid(value) || !updatedCourse.timeline_start) { return updatedCourse; }
    if (isAfter(toDate(updatedCourse.start), toDate(updatedCourse.timeline_start)) && valueKey !== 'timeline_start') {
      updatedCourse.timeline_start = updatedCourse.start;
    }
    if (isAfter(toDate(updatedCourse.timeline_start), toDate(updatedCourse.timeline_end)) && valueKey !== 'timeline_end') {
      updatedCourse.timeline_end = updatedCourse.timeline_start;
    }
    if (isAfter(toDate(updatedCourse.timeline_end), toDate(updatedCourse.end)) && valueKey !== 'end') {
      updatedCourse.end = updatedCourse.timeline_end;
    }
    if (isAfter(toDate(updatedCourse.timeline_start), toDate(updatedCourse.end)) && valueKey !== 'timeline_start') {
      updatedCourse.timeline_start = updatedCourse.end;
    }

    // If the dates were changed by extending the course end, and the assignment end
    // was previously the same as the course end, then extend the timeline end to match.
    if (prevCourse.end === prevCourse.timeline_end && valueKey !== 'timeline_end') {
      updatedCourse.timeline_end = updatedCourse.end;
    }

    return updatedCourse;
  },

  // Maximum tracking length of a year, plus a little bit of wiggle room.
  // We want to make sure long-running events get broken up into smaller
  // segments, because huge long-running courses cause performance problems
  // with the Dashboard data update process.
  MAX_MONTHS: 13,

  courseTooLong(course) {
    return differenceInMonths(toDate(course.end), toDate(course.start)) > this.MAX_MONTHS;
  },

  moreWeeksThanAvailable(course, weeks, exceptions) {
    if (!weeks || !weeks.length) { return false; }
    const nonBlackoutWeeks = filter(
      this.weekMeetings(course, exceptions),
      mtg => mtg.length > 0
    );
    return weeks.length > nonBlackoutWeeks.length;
  },

  wouldCreateBlackoutWeek(course, day, exceptions) {
    const selectedDay = toDate(day);
    let noMeetingsThisWeek = true;
    [0, 1, 2, 3, 4, 5, 6].forEach((i) => {
      const wkDay = format(addDays(startOfWeek(selectedDay), i), 'yyyyMMdd');
      if (this.courseMeets(course.weekdays, i, wkDay, exceptions.join(','))) {
        return (noMeetingsThisWeek = false);
      }
    });
    return noMeetingsThisWeek;
  },

  weeksBeforeTimeline(course) {
    const courseStart = startOfWeek(toDate(course.start));
    const timelineStart = startOfWeek(toDate(course.timeline_start));
    return differenceInWeeks(timelineStart, courseStart);
  },

  // Returns array describing weekday meetings for each week
  // Ex: [["Sunday (01/09)"], ["Sunday (01/16)", "Wednesday (01/19)", "Thursday (01/20)"], []]
  weekMeetings(course, exceptions) {
    const weekEnd = endOfWeek(toDate(course.timeline_end));
    let weekStart = startOfWeek(toDate(course.timeline_start));
    const firstWeekStart = getDay(toDate(course.timeline_start));
    const courseWeeks = differenceInWeeks(weekEnd, weekStart, { roundingMethod: 'round' });
    const meetings = [];

    // eslint-disable-next-line no-restricted-syntax
    for (const week of range(0, (courseWeeks - 1), true)) {
      weekStart = addWeeks(startOfWeek(toDate(course.timeline_start)), week);

      // Account for the first partial week, which may not have 7 days.
      let firstDayOfWeek;
      if (week === 0) {
        firstDayOfWeek = firstWeekStart;
      } else {
        firstDayOfWeek = 0;
      }

      const ms = [];
      // eslint-disable-next-line no-restricted-syntax
      for (const i of range(firstDayOfWeek, 6, true)) {
        const day = addDays(weekStart, i);
        if (course && this.courseMeets(course.weekdays, i, format(day, 'yyyyMMdd'), exceptions)) {
          ms.push(format(day, 'EEEE (MM/dd)'));
        }
      }
      meetings.push(ms);
    }
    return meetings;
  },
  courseMeets(weekdays, i, formatted, exceptions) {
    if (!exceptions && exceptions !== '') {
      return false;
    }
    exceptions = exceptions.split ? exceptions.split(',') : exceptions;

    if (weekdays[i] === '1' && !exceptions.includes(formatted)) {
      return true;
    }
    if (weekdays[i] === '0' && exceptions.includes(formatted)) {
      return true;
    }
    return false;
  },

  // Takes a week weekMeetings array and returns the count of non-empty weeks
  openWeeks(weekMeetings) {
    let openWeekCount = 0;
    weekMeetings.forEach((meetingArray) => {
      if (meetingArray.length > 0) {
        return openWeekCount += 1;
      }
    });
    return openWeekCount;
  },

  isEnded(course) {
    return isBefore(toDate(course.end), new Date());
  },

  currentWeekIndex(timelineStart) {
    const diff = differenceInWeeks(startOfWeek(new Date()), startOfWeek(toDate(timelineStart)));
    return Math.max(diff, 0);
  },

  currentWeekOrder(timelineStart) {
    // Week order is indexed from 1, so we add 1 to the number of weeks that have
    // passed since the start of the timeline to get the current week.
    return this.currentWeekIndex(timelineStart) + 1;
  }
};

function* range(left, right, inclusive) {
  const ascending = left < right;

  let endOfRange;
  if (!inclusive) {
    endOfRange = right;
  } else if (ascending) {
    endOfRange = right + 1;
  } else {
    endOfRange = right - 1;
  }

  for (let i = left; ascending ? i < endOfRange : i > endOfRange; ascending ? i += 1 : i -= 1) {
    yield i;
  }
}

export default CourseDateUtils;