複数の会議室や人の予定を並べて表示するJavaScriptライブラリーEvent Calendar

商用利用が可能なJavaScript製のカレンダーUIライブラリーEvent Calendarを使って、複数のリソースの予約状況を並べて表示するデモを作ってみました。さらに、ステップ・バイ・ステップで、デモを作っていく様子をご紹介します。

# Event Calendarを使う

Event Calendarは複数のリソースを並べて表示できるMITライセンスのカレンダーライブラリーです。素のJavaScript（Vanilla JS）で書かれていて軽量です。READMEが少し不足しているのか、あまりスターがついていないのが残念です。

https://github.com/vkurko/calendar

まずはじめに、Event Calendarを使ったデモをご紹介します。

- 3つのリソース（Room 1, Room 2, Room 3）の予約状況を表示します。
  - 重複する予約はできません。
  - Editable Eventはドラッグやサイズ変更が可能です。
  - Uneditable Eventはマウス操作では変更できません。クリックして表示されるダイアログから変更します。
- カレンダー上の空いている時間をドラッグすると、新しく予約できます。
- 画面右上にある矢印ボタンで日付を変更できます。今日から1週間分のみ表示できます。

<p class="codepen" data-height="750" data-default-tab="result" data-slug-hash="ExOwWXO" data-user="takatama" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/takatama/pen/ExOwWXO">
  Event Calendar Demo</a> by Hirokazu Takatama (<a href="https://codepen.io/takatama">@takatama</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

このデモを作るまでの過程を、3ステップに分けて紹介していきます。

## Step1: カレンダーを表示し、空き時間をドラッグして予定を追加

今回はCDNを使ってライブラリーを読み込みます。Event Calendarの他に、日付の計算するために[day.js](https://day.js.org/)も利用します。

`<body>`にカレンダーを表示する`<div>`（id="ec"）を挿入します。

```html:ec.html
<head>
  <link href="https://cdn.jsdelivr.net/npm/@event-calendar/build@1.5.0/event-calendar.min.css" rel="stylesheet">
</head>
<body>
  <div id="ec"></div>
  <script src="https://cdn.jsdelivr.net/npm/@event-calendar/build@1.5.0/event-calendar.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.9/dayjs.min.js"></script>
</body>
```

表示するリソース`resources`、予定`events`を定義し、`new EventCalendar(element, options)`でカレンダーを生成します。

空き時間をドラッグしたときに予定を追加できるよう、カレンダーのオプションで`selectable: true`にして、新しい予定が追加されたときに呼び出される`select: function(event)`を定義します。

select関数の中では、`addEvent(event)`で予定を追加します。その際、予定の`event.id`と`event.resourceIds[]`を設定しています。

```javascript:ec.js
const resources = [{
    id: 1, title: 'Room 1'
  }, {
    id: 2, title: 'Room 2'
  }, {
    id: 3, title: 'Room 3'
}];

function createDate(hours, minutes) {
  const now = new Date();
  now.setHours(hours);
  now.setMinutes(minutes);
  now.setSeconds(0);
  return now;
}

const events = [{
  id: 1,
  resourceIds: [1],
  start: createDate(9, 0),
  end: createDate(10, 30),
  title: 'Editable Event',
  editable: true,
}, {
  id: 2,
  resourceIds: [2],
  start: createDate(11, 0),
  end: createDate(13, 30),
  title: 'Uneditable Event',
  // editable: false, // not work
  startEditable: false,
  durationEditable: false,
  backgroundColor: 'red',
}];

const ec = new EventCalendar(document.getElementById('ec'), {
  resources,
  events,
  view: 'resourceTimeGridDay',
  allDaySlot: false,
  slotMinTime: '08:00:00',
  slotMaxTime: '19:00:00',
  nowIndicator: true,
  selectable: true,
  select: function(event) {
    addEvent(event);
  }
});

function addEvent(event) {
  event.id = new Date().getTime();
  event.resourceIds = [ event.resource.id ];
  ec.addEvent(event);
  ec.unselect();
}
```

## Step2: 重複する予約は許可しない

重複する予約の追加や変更ができないようにします。

重複する予定がないか調べるために、新しく追加する（変更する）予定と、同じリソースで、かつ、時間帯が重なっている予定を取得する`getOverlappingEvents()`を追加します。この関数を使って、重複する予定の存在を確認する`hasOverlappingEvents()`と、調査対象の予定自体を除いた他の重複する予定を確認する`hasOtherOverlappingEvents()`を定義します。

そして、カレンダー上に新しい予定を追加したときに呼び出される`select`の中で、重複する予定があれば予定の追加は中断します。また、ドロップ操作をしたときの`eventDrop`と、予定をドラッグ操作で変更したときの`eventResize`では、重複する予定があるときに、その操作をなかったことにします（revert）。

```javascript:ec.js
// resources, createDate(), events はそのまま

// ★追加
function getOverlappingEvents(event) {
  // select event has event.resource.id
  // eventDrop event has event.resourceIds
  const rId = event.resource ? event.resource.id : event.resourceIds[0];
  return ec.getEvents().filter(e => e.resourceIds[0] == rId && e => e.start < event.end && event.start < e.end);
}

// ★追加
function hasOverlappingEvents(event) {
  return getOverlappingEvents(event).length > 0;
}

// ★追加
function hasOtherOverlappingEvents(event) {
  return getOverlappingEvents(event).filter(e => e.id != event.id).length > 0
}

const ec = new EventCalendar(document.getElementById('ec'), {
  resources,
  events,
  view: 'resourceTimeGridDay',
  allDaySlot: false,
  slotMinTime: '08:00:00',
  slotMaxTime: '19:00:00',
  nowIndicator: true,
  selectable: true,
  select: function(event) {
    // ★追加
    if (hasOverlappingEvents(event)) {
      ec.unselect();
      return;
    }
    addEvent(event);
  },
  // ★追加
  eventDrop: function ({ event, revert }) {
    if (hasOtherOverlappingEvents(event)) revert();
  },
  // ★追加
  eventResize: function ({ event, revert }) {
    if (hasOtherOverlappingEvents(event)) revert();
  },
});

function addEvent(event) {
  event.id = new Date().getTime();
  event.resourceIds = [ event.resource.id ];
  ec.addEvent(event);
  ec.unselect();
}
```

## Step3: この先1週間の予定だけ表示する

ずっと先の予約ができると困る場合があります。そこで、表示できる期間を制限してみます。

カレンダーのオプションで、画面右上にある矢印ボタンで日付を変更したときに呼び出される`datesSet`を追加します。`start`が表示している日付です。

`toggleDateButtonsFor7Days()`で、`start`が今日から1週間分のときだけボタンを有効にします。無効にしたときにボタンの表示が変わるよう、CSSも追加しておきます。


```javascript:ec.js
const ec = new EventCalendar(document.getElementById('ec'), {
  ...
  },
  // ★追加
  datesSet: function ({ start }) {
    toggleDateButtonsFor7Days(start);
  }
});

// ★追加
function toggleDateButtonsFor7Days(start) {
  const next = document.querySelector('.ec-next');
  const prev = document.querySelector('.ec-prev');
  const now = dayjs();
  const targetDate = dayjs(start);
  prev.disabled = targetDate.isBefore(now);
  next.disabled = targetDate.isAfter(now.add(6, 'day'));  
}
```

```css:ec.css
.ec-prev:disabled, .ec-next:disabled {
  background-color: #ced4da;
}
```

## Step4: モーダルダイアログで予定のコメントを入力する

モーダルダイアログを作るのに便利なHTMLタグ`<dialog>`を使って、予定を作るときにコメントを入力できるようにします。カレンダーのオプション`select`では（`addEvent()`の代わりに）`showModal()`を呼び出します。`dialog`を`submit`するときに、`addEvent()`で予定を追加します。

また、予定をクリックしたときに、ダイアログを表示します。カレンダーのオプションに、`eventClick`を追加して、`showModal()`を呼び出します。このまま`dialog`を`submit`すると`addEvent()`が呼ばれますが、予定の追加ではなく更新にしたいので、`event.id`がある場合は更新だと判断して、カレンダーの`updateEvent()`を呼び出すように変更します。

```html:ec.html
<dialog id="dialog">
  <form id="form" method="dialog">
    <h2>Make Reservation</h2>
    <h3 id="room-name"></h3>
    <h3 id="date"></h3>
    <div class="row">
      <label for="start">Start:</label>
      <input type="time" id="start" name="start-time">
    </div>
    <div class="row">
      <label for="end">End:</label>
      <input type="time" id="end" name="end-time">
    </div>
    <div class="row">
      <label for="comment">Comment:</label>
      <textarea id="comment" name="comment"></textarea>
    </div>
    <menu>
      <button id="cancel" type="reset">Cancel</button>
      <button id="confirm" type="submit">Confirm</button>
    </menu>
  </form>
</dialog>
```

```css:ec.css
dialog {
  width: 400px;
  padding: 20px;
  border: solid 1px;
}

.row {
  display: flex;
  margin: 10px;
}

.row label {
  width: 90px;
}

textarea {
  width: 250px;
}
```

```javascript:ec.js
const ec = new EventCalendar(document.getElementById('ec'), {
  ...
  select: function (event) { // on create event
    if (hasOverlappingEvents(event)) {
      ec.unselect();
      return;
    }
    // ★変更
    showModal(event);
  },
  ...
  // ★追加
  eventClick: function ({ event }) {
    showModal(event);
  },
});

function addEvent(event) {
  // ★追加
  if (event.id) {
    ec.updateEvent(event);
    return;
  }
  event.id = new Date().getTime();
  event.resourceIds = [ event.resource.id ];
  ec.addEvent(event);
  ec.unselect();
}

const dialog = document.querySelector('dialog');

function showModal(event) {
  function getResourceTitle(event) {
    const resourceId = event.resource ? event.resource.id : event.resourceIds[0];
    const resource = resources.find(r => r.id == resourceId);
    return resource ? resource.title : '';
  }
  document.getElementById('room-name').innerText = getResourceTitle(event);
  const startDate = dayjs(event.start);
  document.getElementById('date').innerText = startDate.format('YYYY/MM/DD');
  document.getElementById('start').value = startDate.format('HH:mm');
  document.getElementById('end').value = dayjs(event.end).format('HH:mm');
  document.getElementById('comment').value = event.title || '';
  dialog.event = event;
  dialog.showModal();
}

document.getElementById('form').onsubmit = function(e) {
  e.preventDefault();
  const event = dialog.event;

  const startTime = document.getElementById('start').value.split(':');
  event.start.setHours(Number(startTime[0]));
  event.start.setMinutes(Number(startTime[1]));

  const endTime = document.getElementById('end').value.split(':');
  event.end.setHours(Number(endTime[0]));
  event.end.setMinutes(Number(endTime[1]));

  const comment = document.getElementById('comment').value;
  event.title = comment;
  addEvent(event);
  dialog.close();
}

document.getElementById('cancel').onclick = function() {
  dialog.close();
};
```

## 完成形

最後に、完成したコードを掲載します。

```html:ec.html
Powered by Event Calendar <a target="_blank" href="https://github.com/vkurko/calendar">https://github.com/vkurko/calendar</a>

<div id="ec"></div>

<dialog id="dialog">
  <form id="form" method="dialog">
    <h2>Make Reservation</h2>
    <h3 id="room-name"></h3>
    <h3 id="date"></h3>
    <div class="row">
      <label for="start">Start:</label>
      <input type="time" id="start" name="start-time">
    </div>
    <div class="row">
      <label for="end">End:</label>
      <input type="time" id="end" name="end-time">
    </div>
    <div class="row">
      <label for="comment">Comment:</label>
      <textarea id="comment" name="comment"></textarea>
    </div>
    <menu>
      <button id="cancel" type="reset">Cancel</button>
      <button id="confirm" type="submit">Confirm</button>
    </menu>
  </form>
</dialog>

<script src="https://cdn.jsdelivr.net/npm/@event-calendar/build@1.5.0/event-calendar.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.9/dayjs.min.js"></script>
```

```css:ec.css
.ec-prev:disabled, .ec-next:disabled {
  background-color: #ced4da;
}

dialog {
  width: 400px;
  padding: 20px;
  border: solid 1px;
}

.row {
  display: flex;
  margin: 10px;
}

.row label {
  width: 90px;
}

textarea {
  width: 250px;
}
```

```javascript:ec.js
const resources = [
  { id: 1, title: 'Room 1' }, { id: 2, title: 'Room 2' }, { id: 3, title: 'Room 3'}
];

function createDate(hours, minutes) {
  const now = new Date();
  now.setHours(hours);
  now.setMinutes(minutes);
  now.setSeconds(0);
  return now;
}

const events = [{
  id: 1,
  resourceIds: [1],
  start: createDate(9, 0),
  end: createDate(10, 30),
  title: 'Editable Event',
  editable: true,
}, {
  id: 2,
  resourceIds: [2],
  start: createDate(11, 0),
  end: createDate(13, 30),
  title: 'Uneditable Event',
  // editable: false, // not work
  startEditable: false,
  durationEditable: false,
  backgroundColor: 'red',
}];

function getOverlappingEvents(event) {
  // select event has event.resource.id
  // eventDrop event has event.resourceIds
  const rId = event.resource ? event.resource.id : event.resourceIds[0];
  return ec.getEvents().filter(e => e.resourceIds[0] == rId && e => e.start < event.end && event.start < e.end);
}

function hasOverlappingEvents(event) {
  return getOverlappingEvents(event).length > 0;
}

function hasOtherOverlappingEvents(event) {
  return getOverlappingEvents(event).filter(e => e.id != event.id).length > 0
}

const ec = new EventCalendar(document.getElementById('ec'), {
  resources,
  events,
  view: 'resourceTimeGridDay',
  allDaySlot: false,
  slotMinTime: '08:00:00',
  slotMaxTime: '19:00:00',
  nowIndicator: true,
  selectable: true,
  select: function(event) {
    if (hasOverlappingEvents(event)) {
      ec.unselect();
      return;
    }
    showModal(event);
  },
  eventDrop: function ({ event, revert }) {
    if (hasOtherOverlappingEvents(event)) revert();
  },
  eventResize: function ({ event, revert }) {
    if (hasOtherOverlappingEvents(event)) revert();
  },
  datesSet: function ({ start }) {
    toggleDateButtonsFor7Days(start);
  },
  eventClick: function ({ event }) {
    showModal(event);
  },
});

function toggleDateButtonsFor7Days(start) {
  const next = document.querySelector('.ec-next');
  const prev = document.querySelector('.ec-prev');
  const now = dayjs();
  const targetDate = dayjs(start);
  prev.disabled = targetDate.isBefore(now);
  next.disabled = targetDate.isAfter(now.add(6, 'day'));  
}

function addEvent(event) {
  if (event.id) {
    ec.updateEvent(event);
    return;
  }
  event.id = new Date().getTime();
  event.resourceIds = [ event.resource.id ];
  ec.addEvent(event);
  ec.unselect();
}

const dialog = document.querySelector('dialog');

function showModal(event) {
  function getResourceTitle(event) {
    const resourceId = event.resource ? event.resource.id : event.resourceIds[0];
    const resource = resources.find(r => r.id == resourceId);
    return resource ? resource.title : '';
  }
  document.getElementById('room-name').innerText = getResourceTitle(event);
  const startDate = dayjs(event.start);
  document.getElementById('date').innerText = startDate.format('YYYY/MM/DD');
  document.getElementById('start').value = startDate.format('HH:mm');
  document.getElementById('end').value = dayjs(event.end).format('HH:mm');
  document.getElementById('comment').value = event.title || '';
  dialog.event = event;
  dialog.showModal();
}

document.getElementById('form').onsubmit = function(e) {
  e.preventDefault();
  const event = dialog.event;

  const startTime = document.getElementById('start').value.split(':');
  event.start.setHours(Number(startTime[0]));
  event.start.setMinutes(Number(startTime[1]));

  const endTime = document.getElementById('end').value.split(':');
  event.end.setHours(Number(endTime[0]));
  event.end.setMinutes(Number(endTime[1]));

  const comment = document.getElementById('comment').value;
  event.title = comment;
  addEvent(event);
  dialog.close();
}

document.getElementById('cancel').onclick = function() {
  dialog.close();
};
```