EventCalendar.jsとGoogleスプレッドシートを使った設備予約表

https://github.com/vkurko/calendar

EventCalendar.jsを使うデモの紹介はいくつかありましたが、実際に使用する際にはイベントの情報を保存、共有する必要があると思います。その方法まで踏み込んだ説明やデモがなかったので調べながら構築してみました。
次の記事を参考にさせていただきました。

https://qiita.com/takatama/items/1a5f595b502de8b1e32e

イベント情報の保存先には、インストール不要で無料で利用できるスプレッドシートを選択しました。Webに公開しない限られたグループ内での使用を想定していますので、セキュリティの設定はされていませんのであしからず。

まずは、成果物のデモをご覧ください。

# 成果物
デモのアニメーションとコードを掲載します。
コードの詳細は後で説明します。
実際に動いている画面を見るとイメージしやすいと思います。

## デモ動作
※途中保存ボタンを押した後，内容が消去されて見えるのはブラウザの更新ボタンを押して再読み込みしているためです。

![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253612/00d4bb62-a05b-d439-d20c-681e350e66d8.gif)

## code

~~~javascript:GAS
//ここは各自変更してください。
const SPREAD_SHEET_ID = '＊＊＊＊＊';
const SHEET_NAME = 'シート1';

const today = new Date();

//GETリクエスト時に呼び出される関数
function doGet(e) {
  //スプレッドシートをIDで取得
  const app = SpreadsheetApp.openById(SPREAD_SHEET_ID);
  //シートをシート名で取得
  const sheet = app.getSheetByName(SHEET_NAME);
  //シートの入力内容を全て配列で取得
  const values = sheet.getDataRange().getValues();
  const data = [];

  //シートの入力内容をオブジェクトに詰め替え
  for(let i=0; i<values.length; i++){
    //ヘッダー部(1行目)はスキップ
    if(i === 0)continue;
    const param = {};
    for(let j=0; j<values[i].length; j++){
      if(values[i][3]<today){
        sheet.deleteRow(i+1);
        break;
      }
      if (Object.prototype.toString.call(values[i][j]) == '[object Date]') {
        param[values[0][j]] = Utilities.formatDate(values[i][j], 'JST','yyyy-MM-dd HH:mm:ss');
      }else{
        param[values[0][j]] = values[i][j];
      }
    }
    data.push(param);
  }
  //返却情報を生成
  const result = ContentService.createTextOutput();

    //Mime TypeをJSONに設定
    result.setMimeType(ContentService.MimeType.JSON);

    //JSONテキストをセットする
    result.setContent(JSON.stringify(data));

    return result;
}

function doPost(e){
  const app = SpreadsheetApp.openById(SPREAD_SHEET_ID);
  //シートをシート名で取得
  const sheet = app.getSheetByName(SHEET_NAME);
 var JsonDATA = JSON.parse(e.postData.getDataAsString());
 //~~ (JSONを使った処理) ~~

 sheet.clearContents();
 sheet.getRange(1,1).setValue("id");
 sheet.getRange(1,2).setValue("resourceIds");
 sheet.getRange(1,3).setValue("start");
 sheet.getRange(1,4).setValue("end");
 sheet.getRange(1,5).setValue("title");
 sheet.getRange(1,6).setValue("TIMESTAMP");
 var i = 2;
  JsonDATA.forEach(function(d){
    sheet.getRange(i,1).setValue(d.id);
    sheet.getRange(i,2).setValue(d.resourceIds);
    var date = new Date(d.start);
    var a =Utilities.formatDate(date, 'JST','yyyy-MM-dd HH:mm:ss');
    sheet.getRange(i,3).setValue(Utilities.formatDate(date, 'JST','yyyy-MM-dd HH:mm:ss'));
    date = new Date(d.end);
    sheet.getRange(i,4).setValue(Utilities.formatDate(date, 'JST','yyyy-MM-dd HH:mm:ss'));
    sheet.getRange(i,5).setValue(d.title);
    date = new Date(d.TIMESTAMP);
    sheet.getRange(i,6).setValue(Utilities.formatDate(date, 'JST','yyyy-MM-dd HH:mm:ss.SSS'));
    i++;
  });
}

/*
//デバッグ用
function doPostTest() {
  //eの作成
  var e = {
    postData : Utilities.newBlob('[{"id":"1","resourceIds":"1","start":"2024-11-21T23:00:00.000Z","end":"2024-11-22T00:00:00.000Z","title":"test","TIMESTAMP":"2024-11-20 16:58:26.573"},{"id":"2","resourceIds":"1","start":"2024-11-22T23:00:00.000Z","end":"2024-11-23T00:00:00.000Z","title":"test","TIMESTAMP":""}]')
  };
  //呼び出す。
  doPost(e);
}
*/
~~~

~~~ec.js
//ここは各自変更してください。
const endpoint = "＊＊＊＊＊＊＊";

var events=[];
var now = dayjs();
var ec;
var DOWN_TIME;

fetch(endpoint)
.then(response => response.json())
.then(data => {
    let s = 0;
    const object = data;
    object.forEach(ev=>{
        let tmp_data = {};
        tmp_data.id = ev.id;
        tmp_data.resourceIds = ev.resourceIds;
        tmp_data.start = ev.start;
        tmp_data.end = ev.end;
        tmp_data.title = ev.title;
        if(s==0){
            DOWN_TIME = ev.TIMESTAMP;
        }
        events.push(tmp_data);
        s++;
    });
    createEC(1);
})

var resources=[{id: 1, title: 'room1'}];
/*
設備一覧
id  設備
1   room1
2   room2
3   room3
4   room4
*/
function createEC(r){
    let flg = [];
    switch(r){
        case 1:
            flg = [1,0,0,0];
            break;
        case 2:
            flg = [0,1,0,0];
            break;
        case 3:
            flg = [0,0,1,0];
            break;
        case 4:
            flg = [0,0,0,1];
            break;
        defalut:
            flg = [1,0,0,0];
    }
    ec = new EventCalendar(document.getElementById('ec'), {
    resources,
    firstDay: now.format('d'),
    view: 'resourceTimeGridWeek',
    allDaySlot:false,
    slotMinTime: '06:00:00',
    slotMaxTime: '20:00:00',
    nowIndicator: true,
    selectable: true,
    events,
    datesSet:function(start){
        toggleDateButtonsFor7Days(start);
    },
    customButtons:{
        myBtn1:{
            text: '保存',
            click: async function(){
                let tmp_json = [];
                let eventlist = ec.getEvents();
                let tmp_DOWN_TIME;
                const UP_TIME = dayjs().format('YYYY-MM-DD HH:mm:ss.SSS');
                await fetch(endpoint)
                .then(response => response.json())
                .then(data => {
                    const object = data;
                    let s = 0;
                    object.forEach(ev=>{
                        if(s==0){
                            tmp_DOWN_TIME = ev.TIMESTAMP;
                        }
                        s++;
                    });
                })
                if(tmp_DOWN_TIME == DOWN_TIME){
                    DOWN_TIME = UP_TIME;
                    s = 0;
                    eventlist.forEach(ev=>{
                        let tmp_data = {};
                        tmp_data.id = ev.id;
                        tmp_data.resourceIds = ev.resourceIds[0];
                        tmp_data.start = ev.start;
                        tmp_data.end = ev.end;
                        tmp_data.title = ev.title;
                        if(s==0){
                            tmp_data.TIMESTAMP = UP_TIME;
                        }else{
                            tmp_data.TIMESTAMP = "";
                        }
                        tmp_json.push(tmp_data);
                    });
        
                    let json_data = JSON.stringify(tmp_json);
                    var postparam = 
                    {
                    "method"     : "POST",
                    "mode"       : "no-cors",
                    "Content-Type" : "application/json",
                    "body" : json_data
                    };
                    fetch(endpoint, postparam);
                    alert('保存');
                }else{
                    alert('クラウドのデータが更新されています。\nページを更新してから予約しなおしてください。');
                }
            }
        },
        myBtn2:{
            text: 'room1',
            click: function(){
                resources=[{id: 1, title: 'room1'}];
                ec.destroy();
                createEC(1);
            },
            active: flg[0]
        },
        myBtn3:{
            text: 'room2',
            click: function(){
                resources=[{id: 2, title: 'room2'}];
                ec.destroy();
                createEC(2);
            },
            active: flg[1]
        },
        myBtn4:{
            text: 'room3',
            click: function(){
                resources=[{id: 3, title: 'room3'}];
                ec.destroy();
                createEC(3);
            },
            active: flg[2]
        },
        myBtn5:{
            text: 'room4',
            click: function(){
                resources=[{id: 4, title: 'room4'}];
                ec.destroy();
                createEC(4);
            },
            active: flg[3]
        }
    },
    headerToolbar:{
        start: 'title  myBtn2 myBtn3 myBtn4 myBtn5',
        center: 'myBtn1',
        end:'today prev,next'
    },
    selectable:true,
    select:function(event){
        if (hasOverlappingEvents(event)) {
            ec.unselect();
            return;
        }
        addEvent(event);
        showModal(event);
    },
    eventClick: function ({ event }) {
        showModal2(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();
}

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

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

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

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

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();
}

function showModal2(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-name2').innerText = getResourceTitle(event);
    const startDate = dayjs(event.start);
    document.getElementById('date2').innerText = startDate.format('YYYY/MM/DD');
    document.getElementById('start2').value = startDate.format('HH:mm');
    document.getElementById('end2').value = dayjs(event.end).format('HH:mm');
    document.getElementById('comment2').value = event.title || '';
    dialog2.event = event;
    dialog2.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('form2').onsubmit = function(e) {
    e.preventDefault();
    const event = dialog2.event;
    
    const startTime = document.getElementById('start2').value.split(':');
    event.start.setHours(Number(startTime[0]));
    event.start.setMinutes(Number(startTime[1]));
    
    const endTime = document.getElementById('end2').value.split(':');
    event.end.setHours(Number(endTime[0]));
    event.end.setMinutes(Number(endTime[1]));
    
    const comment = document.getElementById('comment2').value;
    event.title = comment;
    addEvent(event);
    dialog2.close();
}

document.getElementById('cancel').onclick = function(e) {
    e.preventDefault();
    const event = dialog.event;
    ec.removeEventById(event.id);
    dialog.close();
};

document.getElementById('delete2').onclick = function(e) {
    e.preventDefault();
    const event = dialog2.event;
    ec.removeEventById(event.id);
    dialog2.close();
};

document.getElementById('cancel2').onclick = function() {
    dialog2.close();
};
~~~

~~~.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>予約状況確認</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@event-calendar/build@3.7.0/event-calendar.min.css">
<script src="https://cdn.jsdelivr.net/npm/@event-calendar/build@3.7.0/event-calendar.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.13/dayjs.min.js"></script>
<style>
    .ec-prev:disabled, .ec-next:disabled {
    background-color: #ced4da;
  }
  .ec-sat{
    background-color: rgba(200, 235, 247, 0.821);
  }

  .ec-sun{
    background-color: rgba(249, 197, 204, 0.722);
  }
  
  dialog {
    width: 400px;
    padding: 20px;
    border: solid 1px;
  }
  
  .row {
    display: flex;
    margin: 10px;
  }
  
  .row label {
    width: 90px;
  }
  
  textarea {
    width: 250px;
  }
</style>
</head>
<body>
    <h1>予約状況確認</h1>
<div id="ec"></div>

<dialog id="dialog">
    <form id="form" method="dialog">
      <h2>利用予約</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">利用者:</label>
        <textarea id="comment" name="comment"></textarea>
      </div>
      <menu>
        <button id="confirm" type="submit">決定</button>
        <button id="cancel" type="reset">キャンセル</button>
      </menu>
    </form>
  </dialog>

  <dialog id="dialog2">
    <form id="form2" method="dialog">
      <h2>利用予約</h2>
      <h3 id="room-name2"></h3>
      <h3 id="date2"></h3>
      <div class="row">
        <label for="start">Start:</label>
        <input type="time" id="start2" name="start-time2">
      </div>
      <div class="row">
        <label for="end">End:</label>
        <input type="time" id="end2" name="end-time2">
      </div>
      <div class="row">
        <label for="comment">利用者:</label>
        <textarea id="comment2" name="comment2"></textarea>
      </div>
      <menu>
        <button id="confirm2" type="submit">決定</button>
        <button id="delete2" type="submit">削除</button>
        <button id="cancel2" type="reset">キャンセル</button>
      </menu>
    </form>
  </dialog>

<script src="./ec.js"></script>
</body>

</html>

~~~

# 動作の概略
データのフロー

~~~mermaid
sequenceDiagram
    スプレッドシート->>+GAS: ①スプレッドシートの<br/>内容を読み込み
    GAS->>+js: doGet()でJSONデータを作成し、<br/>fetch()で読み込み
    js->>+HTML: DOM操作で画面を作成
    Note over HTML: ユーザーが予定表を操作
    Note over HTML: 保存ボタンを押す
    スプレッドシート->>+GAS: ②スプレッドシートの内容を読み込み
    GAS->>+js: doGet()でJSONデータを作成し、<br/>fetch()で読み込み
    js->>+HTML: タイムスタンプを取得
    Note over HTML: ①で読み込んだデータと②で読み込んだ<br/>データのタイムスタンプを比較
    Note over HTML: ②のタイムスタンプが①と同じなら保存を実行。<br/>そうでない場合は更新を促すメッセージを表示。
    HTML-->>-js: イベントリストを読み込み、<br/>JSONデータを作成
    js-->>-GAS: fetch()でデータを送信、<br/>doPost()で受け取ったデータを展開
    GAS-->>-スプレッドシート: シートの内容を全削除して<br/>受け取ったデータに置き換える
    
~~~
予約の内容はすべてスプレッドシートで管理します。
ページの初期化時、スプレッドシートに保存されているイベントを取得します。
スプレッドシートへのアクセスには、GAS(Google Apps Script)を使用します。
GASの設定は、以下の記事を参照してください。

https://qiita.com/ryojio2k/items/3f0310647f2d3b278fe2

https://qiita.com/ryojio2k/items/47ee40947c86cbc0e238


# コードの説明（GAS)

GASで設定したコードの説明を記します。
まず、以下の部分です。
~~~javascript:GAS
//ここは各自変更してください。
const SPREAD_SHEET_ID = '＊＊＊＊＊';
const SHEET_NAME = 'シート1';
~~~
スプレッドシートを作成するとスプレッドシートのURLが発行されます。
~~~
https://docs.google.com/spreadsheets/d/***/edit?gid=0#gid=0
~~~
このURLの***の部分がIDとなっていますので、コピーしてSPREAD_SHEET_IDの '＊＊＊＊＊' 部分を置き換えてください。
SHEET_NAMEはデフォルトのままであれば、'シート1'です。ご自分で用意されたスプレッドシートのシート名を確認してください。

## doGet()
次に、スプレッドシートの内容をJSONで取得するための部分です。
まず、今日より前のイベントデータは不要のため、半自動で削除されるようにコードを記述しています。
``new Date()``関数によって実行したタイミングの日時が取得できます。
イベント終了時刻が取得した現在の日時より前であれば、その行を削除することでイベントデータが削除され、スプレッドシートの肥大化を防ぐようにしました。

~~~javascript:GAS
const today = new Date();

//GETリクエスト時に呼び出される関数
function doGet(e) {
  //スプレッドシートをIDで取得
  const app = SpreadsheetApp.openById(SPREAD_SHEET_ID);
  //シートをシート名で取得
  const sheet = app.getSheetByName(SHEET_NAME);
  //シートの入力内容を全て配列で取得
  const values = sheet.getDataRange().getValues();
  const data = [];

  //シートの入力内容をオブジェクトに詰め替え
  for(let i=0; i<values.length; i++){
    //ヘッダー部(1行目)はスキップ
    if(i === 0)continue;
    const param = {};
    for(let j=0; j<values[i].length; j++){
      //イベントの終了日時がこの関数を呼ばれた時点の日時より前なら該当行（イベント）を削除する。
      if(values[i][3]<today){
        sheet.deleteRow(i+1);
        break;
      }
      if (Object.prototype.toString.call(values[i][j]) == '[object Date]') {
        param[values[0][j]] = Utilities.formatDate(values[i][j], 'JST','yyyy-MM-dd HH:mm:ss');
      }else{
        param[values[0][j]] = values[i][j];
      }
    }
    data.push(param);
  }
  //返却情報を生成
  const result = ContentService.createTextOutput();

    //Mime TypeをJSONに設定
    result.setMimeType(ContentService.MimeType.JSON);

    //JSONテキストをセットする
    result.setContent(JSON.stringify(data));

    return result;
}
~~~

## doPost()
データの受け取りについては、以下の記事を参考としました。

https://qiita.com/khidaka/items/ebf770591100b1eb0eff

~~~javascript:GAS
function doPost(e){
  const app = SpreadsheetApp.openById(SPREAD_SHEET_ID);
  //シートをシート名で取得
  const sheet = app.getSheetByName(SHEET_NAME);
 var JsonDATA = JSON.parse(e.postData.getDataAsString());
 //~~ (JSONを使った処理) ~~

 sheet.clearContents();
 sheet.getRange(1,1).setValue("id");
 sheet.getRange(1,2).setValue("resourceIds");
 sheet.getRange(1,3).setValue("start");
 sheet.getRange(1,4).setValue("end");
 sheet.getRange(1,5).setValue("title");
 sheet.getRange(1,6).setValue("TIMESTAMP");
 var i = 2;
  JsonDATA.forEach(function(d){
    sheet.getRange(i,1).setValue(d.id);
    sheet.getRange(i,2).setValue(d.resourceIds);
    var date = new Date(d.start);
    var a =Utilities.formatDate(date, 'JST','yyyy-MM-dd HH:mm:ss');
    sheet.getRange(i,3).setValue(Utilities.formatDate(date, 'JST','yyyy-MM-dd HH:mm:ss'));
    date = new Date(d.end);
    sheet.getRange(i,4).setValue(Utilities.formatDate(date, 'JST','yyyy-MM-dd HH:mm:ss'));
    sheet.getRange(i,5).setValue(d.title);
    date = new Date(d.TIMESTAMP);
    sheet.getRange(i,6).setValue(Utilities.formatDate(date, 'JST','yyyy-MM-dd HH:mm:ss.SSS'));
    i++;
  });
}
~~~
受け取ったデータと保存されているデータを突き合わせて更新されている部分だけ修正しようかとも思いましたが、~~正直面倒だったので~~結果的に大差ないと思いましたので一度データを全部消去して、受け取ったデータに置き換えるようにしました。
その際に、ヘッダー行の内容も全部消えてしまうので、ヘッダー行を記入する処理を行っています。
~~~javascript
 sheet.clearContents();//書式は残したまま、セルの内容のみ削除
 sheet.getRange(1,1).setValue("id");
 sheet.getRange(1,2).setValue("resourceIds");
 sheet.getRange(1,3).setValue("start");
 sheet.getRange(1,4).setValue("end");
 sheet.getRange(1,5).setValue("title");
 sheet.getRange(1,6).setValue("TIMESTAMP");
~~~

あとは、受け取ったデータを各セルに入力するだけです。
~~~javascript
 var i = 2;
  JsonDATA.forEach(function(d){
    sheet.getRange(i,1).setValue(d.id);
    sheet.getRange(i,2).setValue(d.resourceIds);
    var date = new Date(d.start);
    var a =Utilities.formatDate(date, 'JST','yyyy-MM-dd HH:mm:ss');
    sheet.getRange(i,3).setValue(Utilities.formatDate(date, 'JST','yyyy-MM-dd HH:mm:ss'));
    date = new Date(d.end);
    sheet.getRange(i,4).setValue(Utilities.formatDate(date, 'JST','yyyy-MM-dd HH:mm:ss'));
    sheet.getRange(i,5).setValue(d.title);
    date = new Date(d.TIMESTAMP);
    sheet.getRange(i,6).setValue(Utilities.formatDate(date, 'JST','yyyy-MM-dd HH:mm:ss.SSS'));
    i++;
  });
}
~~~
このとき、少しハマったのが3列目から6列目の日付データの取扱いです。
JSONからパースしてきたデータは```String```型です。そして日付のフォーマットを指定する```Utilities.formatDate()```関数の第一引数である```date```は```Date```型が要求されます。
これに気付かず```Utilities.formatDate(d.start,'JST','yyyy-MM-dd HH:mm:ss')```としてエラーになっていました。

また、```doPost()```だけではGASエディタ上でデバッグが機能しません（外部からのPostを待つことができない）ので、デバッグ用の```doPostTest()```関数を用意して、Postで渡されてくるデータを直接渡してデバッグします。

# コードの説明(Javascript)

基本的な内容は、冒頭で紹介した記事のコピーですので、追加した内容のみ解説します。
~~~javascript
//ここは各自変更してください。
const endpoint = "＊＊＊＊＊＊＊";

var events=[];//イベント格納用配列pushで連想配列を追加していく
var now = dayjs();//現在時刻のバー表示用
var ec;//DOM操作のための入れ物
var DOWN_TIME;/*最初にイベントデータをfetchしてきたときに取得した
スプレッドシートに保存されていたタイムスタンプ*/

//スプレッドシートのデータを取得
fetch(endpoint)
.then(response => response.json())
.then(data => {
    let s = 0;
    const object = data;
    object.forEach(ev=>{
        let tmp_data = {};
        /*
         *{"id":"xx",
         "resourceIds":"yy",
         "start":"ss",
         "end":"ee",
         "title":"tt"} の形に整形
         */
        tmp_data.id = ev.id;
        tmp_data.resourceIds = ev.resourceIds;
        tmp_data.start = ev.start;
        tmp_data.end = ev.end;
        tmp_data.title = ev.title;
        if(s==0){
            /*最初のイベントのタイムスタンプだけ利用
            (ほかの行の時刻も同じのはずだから）*/
            DOWN_TIME = ev.TIMESTAMP;
        }
        events.push(tmp_data);
        s++;
    });
    createEC(1);//予定表をリソースID：1で作画
})

//初期ロード時のリソース（設備）
var resources=[{id: 1, title: 'room1'}];
/*
設備一覧
id  設備
1   room1
2   room2
3   room3
4   room4
*/
function createEC(r){
    let flg = [];//リソース選択ボタンの選択済みフラグ管理用配列
    switch(r){
        case 1:
            flg = [1,0,0,0];
            break;
        case 2:
            flg = [0,1,0,0];
            break;
        case 3:
            flg = [0,0,1,0];
            break;
        case 4:
            flg = [0,0,0,1];
            break;
        defalut:
            flg = [1,0,0,0];
    }
    ec = new EventCalendar(document.getElementById('ec'), {
    resources,
    firstDay: now.format('d'),/*予定表の一番左端に表示する曜日の設定
    今日の曜日を常に左端になるようにする*/
    view: 'resourceTimeGridWeek',//予定表の表示形式
    allDaySlot:false,//時刻割の上段に1日表示の枠があるが、それを非表示に
    slotMinTime: '06:00:00',//時刻割の表示範囲設定
    slotMaxTime: '20:00:00',
    nowIndicator: true,//時刻割に現在の時刻を表す赤いバーが追加される
    selectable: true,//時刻割を選択することができる
    events,//イベントが格納される配列
    datesSet:function(start){
        //日付送りの＜＞ボタンを押したときに実行される関数
        toggleDateButtonsFor7Days(start);
    },
    customButtons:{/*独自に設定可能なボタン
    "myBtn"という名前の部分も自由に設定できる
    */
        myBtn1:{
            text: '保存',
            click: async function(){
            /*async awaitをつけないとデータの読み込みが
            終わる前に処理が進行してしまう
            
            保存する前にスプレッドシートが別の人によって
            更新されていないか、スプレッドシート上の
            タイムスタンプとダウンロードしてきたシートの
            タイムスタンプが同じか比較して確認する*/
                let tmp_json = [];
                let eventlist = ec.getEvents();
                let tmp_DOWN_TIME;
                const UP_TIME = dayjs().format('YYYY-MM-DD HH:mm:ss.SSS');
                await fetch(endpoint)
                .then(response => response.json())
                .then(data => {
                    const object = data;
                    let s = 0;
                    object.forEach(ev=>{
                        if(s==0){
                            tmp_DOWN_TIME = ev.TIMESTAMP;
                        }
                        s++;
                    });
                })
                if(tmp_DOWN_TIME == DOWN_TIME){
                    DOWN_TIME = UP_TIME;
                    s = 0;
                    eventlist.forEach(ev=>{
                        let tmp_data = {};
                        tmp_data.id = ev.id;
                        tmp_data.resourceIds = ev.resourceIds[0];
                        tmp_data.start = ev.start;
                        tmp_data.end = ev.end;
                        tmp_data.title = ev.title;
                        if(s==0){
                            tmp_data.TIMESTAMP = UP_TIME;
                        }else{
                            tmp_data.TIMESTAMP = "";
                        }
                        tmp_json.push(tmp_data);
                    });
        
                    let json_data = JSON.stringify(tmp_json);
                    var postparam = 
                    {
                    "method"     : "POST",
                    "mode"       : "no-cors",
                    "Content-Type" : "application/json",
                    "body" : json_data
                    };
                    fetch(endpoint, postparam);
                    alert('保存');
                }else{
                    alert('クラウドのデータが更新されています。\nページを更新してから予約しなおしてください。');
                }
            }
        },
        myBtn2:{
            text: 'room1',
            click: function(){
                resources=[{id: 1, title: 'room1'}];
                ec.destroy();//一度予定表のDOM要素を破棄して
                createEC(1);//指定のリソースIDで生成しなおす
            },
            active: flg[0]
        },
        myBtn3:{
            text: 'room2',
            click: function(){
                resources=[{id: 2, title: 'room2'}];
                ec.destroy();
                createEC(2);
            },
            active: flg[1]
        },
        myBtn4:{
            text: 'room3',
            click: function(){
                resources=[{id: 3, title: 'room3'}];
                ec.destroy();
                createEC(3);
            },
            active: flg[2]
        },
        myBtn5:{
            text: 'room4',
            click: function(){
                resources=[{id: 4, title: 'room4'}];
                ec.destroy();
                createEC(4);
            },
            active: flg[3]
        }
    },
    headerToolbar:{
        /*各要素の配置場所を設定する。
        細かい配置はEventCalendar.jsに含まれる
        cssが制御しているようなので詳細は不明*/
        start: 'title  myBtn2 myBtn3 myBtn4 myBtn5',
        center: 'myBtn1',
        end:'today prev,next'
    },
    selectable:true,
    select:function(event){
    /*
    予定がないときにイベントを追加するときにも
    ダイアログを表示したかったのと、
    すでにあるイベントを編集するときに「削除」ボタンを追加したかったので
    ダイアログを分けるように変更した。
    */
        if (hasOverlappingEvents(event)) {
            ec.unselect();
            return;
        }
        addEvent(event);
        showModal(event);
    },
    eventClick: function ({ event }) {
        showModal2(event);
    }
    });    
}
~~~
<details><summary>記事のコード部分{addEvent()～showModal()}（一部修正）</summary>

~~~diff_javascript
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();
}

function getOverlappingEvents(event) {
    const rId = event.resource ? event.resource.id : event.resourceIds[0];
    //アローポインタは1回でいい
    + return ec.getEvents().filter(e => e.resourceIds[0] == rId && e.start < event.end && event.start < e.end);
    - 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 toggleDateButtonsFor7Days(start) {
    const next = document.querySelector('.ec-next');
    const prev = document.querySelector('.ec-prev')
    let now = dayjs();
    const targetDate = dayjs(start.startStr);
    prev.disabled = targetDate.isBefore(now);
    //先の日付を2週間分にした
    + next.disabled = targetDate.isAfter(now.add(13, 'day'));  
    - next.disabled = targetDate.isAfter(now.add(6, 'day'));  
}

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();
}
~~~
</details>

~~~javascript
//「削除」ボタンありのダイアログ
const dialog2 = document.querySelector('#dialog2');
function showModal2(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-name2').innerText = getResourceTitle(event);
    const startDate = dayjs(event.start);
    document.getElementById('date2').innerText = startDate.format('YYYY/MM/DD');
    document.getElementById('start2').value = startDate.format('HH:mm');
    document.getElementById('end2').value = dayjs(event.end).format('HH:mm');
    document.getElementById('comment2').value = event.title || '';
    dialog2.event = event;
    dialog2.showModal();
}

//初めてイベントを追加するときのダイアログで「OK」を押したときの処理
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();
}

//イベントをクリックして表示されたダイアログの「OK」を押したときの処理
document.getElementById('form2').onsubmit = function(e) {
    e.preventDefault();
    const event = dialog2.event;
    
    const startTime = document.getElementById('start2').value.split(':');
    event.start.setHours(Number(startTime[0]));
    event.start.setMinutes(Number(startTime[1]));
    
    const endTime = document.getElementById('end2').value.split(':');
    event.end.setHours(Number(endTime[0]));
    event.end.setMinutes(Number(endTime[1]));
    
    const comment = document.getElementById('comment2').value;
    event.title = comment;
    addEvent(event);
    dialog2.close();
}
//初めてイベントを追加するときに表示されたダイアログの「キャンセル」ボタンを押したときの処理
document.getElementById('cancel').onclick = function(e) {
    e.preventDefault();
    const event = dialog.event;
    //処理の構成上、すでにイベントとして追加されてしまっているため、削除する。
    ec.removeEventById(event.id);
    dialog.close();
};
//イベントをクリックして表示されたダイアログの「削除」を押したときの処理
document.getElementById('delete2').onclick = function(e) {
    e.preventDefault();
    const event = dialog2.event;
    ec.removeEventById(event.id);
    dialog2.close();
};
//イベントをクリックして表示されたダイアログの「キャンセル」を押したときの処理
document.getElementById('cancel2').onclick = function() {
    dialog2.close();
};
~~~

# コードの説明(HTML)
とくに難しいことはしていませんのでさらっと。
次の部分がライブラリのロードに当たります。
今回はCDN利用にしています。

~~~.html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@event-calendar/build@3.7.0/event-calendar.min.css">
<script src="https://cdn.jsdelivr.net/npm/@event-calendar/build@3.7.0/event-calendar.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.13/dayjs.min.js"></script>
~~~

追加のcssはわざわざ別に分ける必要もないくらい短いので直書きとしています。
曜日の要素には自動的に```ec-ddd```というクラスが割り振られていますので、土曜日(sat)は水色系、日曜日(sun)はピンク系の背景となるようにしています。
~~~css
<style>
    .ec-prev:disabled, .ec-next:disabled {
    background-color: #ced4da;
  }
  .ec-sat{
    background-color: rgba(200, 235, 247, 0.821);
  }

  .ec-sun{
    background-color: rgba(249, 197, 204, 0.722);
  }
  
  dialog {
    width: 400px;
    padding: 20px;
    border: solid 1px;
  }
  
  .row {
    display: flex;
    margin: 10px;
  }
  
  .row label {
    width: 90px;
  }
  
  textarea {
    width: 250px;
  }
</style>
~~~

あとは本体部分ですが、本質的に必要なのは以下だけです。
divタグにid名"ec"をつけて、javascriptでDOM操作を行っています。
~~~html
<body>
    <div id="ec"></div>
    <script src="./ec.js"></script>
</body>

</html>
~~~

イベントの編集にダイアログを利用しています。
ここも紹介記事からそのまま移植した部分ですので詳しくは解説しません。
ダイアログを追加する場合には、要素のidが被らないように注意してください。
~~~html
<dialog id="dialog">
    <form id="form" method="dialog">
      <h2>利用予約</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">利用者:</label>
        <textarea id="comment" name="comment"></textarea>
      </div>
      <menu>
        <button id="confirm" type="submit">決定</button>
        <button id="cancel" type="reset">キャンセル</button>
      </menu>
    </form>
</dialog>
~~~

# 編集後記
長々とご覧いただきありがとうございました。
早速使ってもらったところ、便利～と喜んでもらえていて満足です。
改善要望も受けたのでリファクタリングしながら機能もブラッシュアップしていけたらと思っています。