vj4/ui/components/calendar/index.js

Summary

Maintainability
F
3 days
Test Coverage
import _ from 'lodash';
import moment from 'moment';
import tpl from 'vj/utils/tpl';

export default class Calendar {
  constructor(events) {
    this.$dom = $(tpl`
      <div class="calendar">
        <div class="calendar__header">
          <button name="prev"><span class="icon icon-chevron_left"></span></button>
          <h1 class="calendar__header__title"></h1>
          <button name="next"><span class="icon icon-chevron_right"></span></button>
        </div>
        <div class="calendar__week-title">
          <table>
            <thead><tr>
              <th>SUN</th>
              <th>MON</th>
              <th>TUE</th>
              <th>WED</th>
              <th>THU</th>
              <th>FRI</th>
              <th>SAT</th>
            </tr></thead>
          </table>
        </div>
        <div class="calendar__body-container"></div>
      </div>
    `);
    this.events = events.map(ev => ({
      ...ev,
      beginAt: moment(ev.beginAt),
      endAt: moment(ev.endAt),
      maskFrom: ev.maskFrom ? moment(ev.maskFrom) : null,
    }));
    this.$dom.find('[name="prev"]').click(() => this.navToPrev());
    this.$dom.find('[name="next"]').click(() => this.navToNext());
    this.$lastBody = null;
    this.navToToday();
  }

  getDom() {
    return this.$dom;
  }

  navToToday() {
    if (this.animating) {
      return;
    }
    this.current = moment().date(1);
    this.update();
  }

  navToNext() {
    if (this.animating) {
      return;
    }
    this.current.add('months', 1);
    this.update(1);
  }

  navToPrev() {
    if (this.animating) {
      return;
    }
    this.current.subtract('months', 1);
    this.update(-1);
  }

  update(direction = 0) {
    this.updateHeader();
    this.updateBody(direction);
  }

  updateHeader() {
    this.$dom.find('.calendar__header__title').text(this.current.format('MMMM YYYY'));
  }

  async updateBody(direction) {
    this.animating = true;
    const $newBody = this.buildBody();
    $newBody.appendTo(this.$dom.find('.calendar__body-container'));
    if (this.$lastBody !== null) {
      this.$lastBody
        .addClass('exit')
        .transition({
          y: direction * 100,
          opacity: 0,
        }, {
          duration: 500,
          easing: 'easeOutCubic',
        });
      await $newBody
        .css({
          y: -direction * 100,
          opacity: 0,
        })
        .transition({
          y: 0,
          opacity: 1,
        }, {
          duration: 500,
          easing: 'easeOutCubic',
        })
        .promise();
      this.$lastBody.remove();
    }
    this.$lastBody = $newBody;
    this.animating = false;
  }

  buildBody() {
    const data = this.buildBodyData();
    const $body = $('<div class="calendar__body"></div>');
    data.forEach(week => {
      const $row = $(tpl`<div class="calendar__row">
        <div class="calendar__row__bg"><table><tbody><tr></tr></tbody></table></div>
        <div class="calendar__row__content"><table><thead><tr></tr></thead><tbody></tbody></table></div>
      </div>`);
      const $bgContainer = $row.find('.calendar__row__bg tr');
      const $numContainer = $row.find('.calendar__row__content thead tr');
      const $bannerContainer = $row.find('.calendar__row__content tbody');
      week.days.forEach(day => {
        const isInactive = day.active ? '' : 'is-inactive';
        const isCurrentDay = day.current ? 'is-current-day' : '';
        const today = day.current ? ' (TODAY)' : '';
        $bgContainer.append($('<td></td>').addClass(isInactive).addClass(isCurrentDay));
        $numContainer.append($(tpl`<th>${day.date.format('D')}${today}</th>`).addClass(isInactive).addClass(isCurrentDay));
      });
      week.banners.forEach(banners => {
        const $tr = $('<tr/>');
        banners.forEach(bannerSpan => {
          if (!bannerSpan.banner) {
            $tr.append(tpl`<td colspan="${bannerSpan.span}"></td>`);
            return;
          }
          const $cell = $(tpl`<td colspan="${bannerSpan.span}"></td>`);
          const $banner = $(tpl`
            <a
              href="${bannerSpan.banner.event.link}"
              class="calendar__banner color-${bannerSpan.banner.event.colorIndex}"
            >${bannerSpan.banner.mask ? bannerSpan.banner.event.maskTitle : bannerSpan.banner.event.title}</a>
          `);
          if (bannerSpan.banner.mask) {
            $banner.addClass('is-masked');
          }
          if (bannerSpan.banner.beginTrunc) {
            $banner.addClass('is-trunc-begin');
          } else if (bannerSpan.banner.beginSnap) {
            $banner.addClass('is-snap-begin');
          }
          if (bannerSpan.banner.endTrunc) {
            $banner.addClass('is-trunc-end');
          } else if (bannerSpan.banner.endSnap) {
            $banner.addClass('is-snap-end');
          }
          $cell.append($banner);
          $tr.append($cell);
        });
        $bannerContainer.append($tr);
      });
      $body.append($row);
    });
    return $body;
  }

  buildBodyData() {
    const now = moment();
    const days = [];
    {
      // back fill
      const base = this.current.clone();
      const dayOfWeek = base.day();
      if (dayOfWeek > 0) {
        base.subtract(dayOfWeek + 1, 'days');
        for (let i = dayOfWeek; i > 0; --i) {
          days.push({
            active: false,
            date: base.add(1, 'days').clone(),
          });
        }
      }
    }
    {
      // current month
      const base = this.current.clone();
      while (base.month() === this.current.month()) {
        days.push({
          active: true,
          date: base.clone(),
        });
        base.add(1, 'days');
      }
    }
    {
      // forward fill
      const base = this.current.clone().add(1, 'months').subtract(1, 'days');
      const dayOfWeek = base.day();
      if (dayOfWeek < 6) {
        for (let i = dayOfWeek; i < 6; ++i) {
          days.push({
            active: false,
            date: base.add(1, 'days').clone(),
          });
        }
      }
    }

    days.forEach(day => {
      day.current = day.date.isSame(now, 'day'); // eslint-disable-line no-param-reassign
    });

    const daysByWeek = _.chunk(days, 7);

    const numberOfWeeks = days.length / 7;
    const bannersByWeek = _.fill(new Array(numberOfWeeks), 1).map(() => []);
    const beginDate = days[0].date.clone();
    const endDate = _.last(days).date.clone();

    // cut events by week to banners
    this.events.forEach(ev => {
      if (ev.endAt.isBefore(ev.beginAt, 'day')) {
        return;
      }
      if (ev.endAt.isBefore(beginDate, 'day') || ev.beginAt.isAfter(endDate, 'day')) {
        return;
      }
      if (ev.beginAt.hour() >= 22) {
        ev.beginAt.add(1, 'day').startOf('day');
      }
      if (ev.endAt.hour() <= 2) {
        ev.endAt.subtract(1, 'day').endOf('day');
      }
      let [bannerBegin, bannerBeginTruncated] = [ev.beginAt.clone(), false];
      if (bannerBegin.isBefore(beginDate, 'day')) {
        [bannerBegin, bannerBeginTruncated] = [beginDate.clone(), true];
      }
      do {
        const bannerEndMax = bannerBegin.clone().endOf('week');
        let [bannerEnd, bannerEndTruncated] = [ev.endAt.clone(), false];
        if (bannerEnd.isAfter(bannerEndMax, 'day')) {
          [bannerEnd, bannerEndTruncated] = [bannerEndMax, true];
        }
        const weekIndex = bannerBegin.clone().startOf('day').diff(beginDate.clone().startOf('day'), 'week');
        bannersByWeek[weekIndex].push({
          beginAt: bannerBegin.startOf('day'),
          beginTrunc: bannerBeginTruncated,
          endAt: bannerEnd.endOf('day'),
          endTrunc: bannerEndTruncated,
          event: ev,
        });
        if (!bannerEndTruncated) {
          break;
        }
        [bannerBegin, bannerBeginTruncated] = [bannerEnd.clone().add(1, 'day'), true];
      } while (!bannerBegin.isAfter(endDate, 'day'));
    });

    // layout banners
    const layout = bannersByWeek
      .map(banners => _
        .sortBy(banners, [
          banner => banner.beginAt.valueOf(),
          banner => (banner.beginTrunc ? 0 : 1), // truncated events first
          banner => -banner.endAt.valueOf(), // long events first
        ]))
      .map(banners => {
        const dayBitmap = _
          .fill(new Array(7), 1)
          .map(() => []);
        banners.forEach(banner => {
          const beginDay = banner.beginAt.day();
          const endDay = banner.endAt.day();
          // find available space
          const vIndexMax = _.max(_
            .range(beginDay, endDay + 1)
            .map(day => dayBitmap[day].length));
          let vIndex = 0;
          for (; vIndex < vIndexMax; ++vIndex) {
            if (_.every(_
              .range(beginDay, endDay + 1)
              .map(day => !dayBitmap[day][vIndex]) // eslint-disable-line no-loop-func
            )) { // eslint-disable-line function-paren-newline
              break;
            }
          }
          // fill space
          for (let i = beginDay; i <= endDay; ++i) {
            dayBitmap[i][vIndex] = banner;
          }
        });
        // merge adjacent cells and arrange banners by vertical index
        const vMaxLength = _.max(_.range(0, 7).map(day => dayBitmap[day].length));
        const weekBanners = _
          .fill(new Array(vMaxLength), 1)
          .map(() => []);
        for (let vIndex = 0; vIndex < vMaxLength; ++vIndex) {
          let last = { span: 1, banner: dayBitmap[0][vIndex] };
          weekBanners[vIndex].push(last);
          for (let day = 1; day < 7; ++day) {
            const banner = dayBitmap[day][vIndex];
            if (banner !== last.banner) {
              last = { span: 1, banner };
              weekBanners[vIndex].push(last);
            } else {
              last.span++;
            }
          }
        }
        // cut banners by masked areas, scanning from left to right
        weekBanners.forEach(bannerSpans => {
          for (let i = 0; i < bannerSpans.length; ++i) {
            const { banner } = bannerSpans[i];
            if (!banner) {
              continue;
            }
            if (banner.mask) {
              continue;
            }
            if (!banner.event.maskFrom) {
              continue;
            }
            if (banner.event.maskFrom.isSame(banner.event.endAt)) {
              // do not show masks if maskFrom === endAt
              continue;
            }
            if (banner.event.endAt.isSame(banner.event.beginAt, 'day')) {
              // do not show masks if endAt - beginAt <= 1 day
              continue;
            }
            if (banner.event.maskFrom.isAfter(banner.endAt, 'day')) {
              // we are not in the time region for masking
              continue;
            }
            if (banner.event.maskFrom.isSameOrBefore(banner.beginAt, 'day')) {
              // mask begins before this banner: replace current banner with masked banner
              banner.mask = true;
            } else {
              // mask begins during this banner: cut current banner into two pieces
              const newBanner = {
                ...banner,
                beginAt: banner.event.maskFrom.clone(),
                beginSnap: true,
                beginTrunc: false,
                mask: true,
              };
              const newBannerSpan = {
                span: newBanner.endAt.day() - newBanner.beginAt.day() + 1,
                banner: newBanner,
              };
              banner.endAt = banner.event.maskFrom.clone().subtract(1, 'day');
              banner.endSnap = true;
              bannerSpans[i].span -= newBannerSpan.span; // eslint-disable-line no-param-reassign
              bannerSpans.splice(i + 1, 0, newBannerSpan);
              i++;
            }
          }
        });
        return weekBanners;
      });
    return daysByWeek
      .map((daysInWeek, weekIndex) => ({
        days: daysInWeek,
        banners: layout[weekIndex],
      }));
  }
}