Google Calendar の当番表を自動で埋める

以前、以下の記事で紹介した様に、自分が働く Kaizen Platform のエンジニアチームは、Google Calendar を使って、2つの当番表を管理しています。

» Google Calendar に登録している当番表を使って Slack Room のトピックを更新する

このカレンダーの管理は、言い出しっ屁の手前、今まで、自分が手作業で行っていました。

この作業は、ひたすら、一覧からカレンダーにコピペするという、耐えがたい単純作業なので、弊社の採用ページでも公言している、 3度同じ事を繰り返す時は自動化する というモットーに基づき、Google App Script を用いて半自動化しました。

使い方

マスター管理シート で、冒頭のスクリーンショットに表示されている、ドロップダウンから、Fill in next interval という項目を選択するだけで、 スプレッドシート上の B カラムに新しいインターバルの日付が入力され、カレンダー上の該当の日付に担当者の名前が登録されます。

当たり前ですが、土日休日は休めます。

デプロイは週二回で、1日目: 検品環境、2日目: 本番環境にデプロイします。

設定

設定用のシート _Settings で上の様な設定表を作り、シート名: LiveOps, Deployment に対応するカレンダー ID を管理します。

カラム: Step は、当番一回につき、担当する日数です。弊社の場合は、LiveOps: 1日、デプロイ: 2日 (検品+本番) です。

シート名: Holidays として管理しているカレンダーは、休暇カレンダーです。現時点では、一つの休暇カレンダーに対応しています。

この例では、日本の休日: en.japanese#holiday@group.v.calendar.google.com を設定しています。

カレンダー ID は Google Calendar のカレンダー設定画面に表示されています。

スクリプト

スプレッドシート画面の Tools > Script editor の項目を選択し、スクリプトエディタを起動します。

テンプレートを選択するダイアログが出てきますが、Close ボタンで閉じてください。

以下のコードを貼り付けます。

//
// Utilities
//

function forEachRows(rows, startIndex, callback) {
  if(arguments.length === 2 && typeof startIndex === "function") {
    callback = startIndex;
    startIndex = 0;
  }
  var numRows = rows.getNumRows()
    , values = rows.getValues()
    ;
  for (var i = startIndex; i < numRows; i++) {
    var row = values[i]
      , isLast = i === numRows - 1;
    if (!row[0] || callback(row, i, isLast) === false) break;
  }
  return rows;
};

//
// Accessors
//

function getJapaneseHolidays(startTime) {
  var endTime = getDateByAddingDate(startTime, 365)
    , events =  getCalendarByName("Holidays").getEvents(startTime, endTime)
    , eventsNum = events.length
    , ret = []
    ;
  for (var i = 0; i < eventsNum; i++) {
    ret.push(getYMDFormattedDate(events[i].getStartTime()));
  }
  return ret;
};

function getLastDate(rows) {
  var found = null
    , index = -1
    , isLast = undefined
    ;
  forEachRows(rows, function(row, i, l) {
    if(row[1] instanceof Date) {
      found = row[1];
      index = i;
      isLast = l;
    } else
      return false;
  });
  return { date: found, index: index, isLast: isLast };
};

function getDateByAddingDate(date, offset) {
  return new Date(date.getTime() + offset * 24 * 60 * 60 * 1000);
};

function getYMDFormattedDate(date) {
  var fmt = function(i) { return i < 10 ? "0" + i : i + "" };
  return [
    date.getFullYear(),
    fmt(date.getMonth() + 1),
    fmt(date.getDate())
  ].join("/");
};

function getDatesToFill(rows, step) {
  var lastDate = getLastDate(rows)
    , startDate = lastDate.date
    , isLast = lastDate.isLast
    , startIndex = isLast ? 0 : (lastDate.index + 1)
    , holidays = getJapaneseHolidays(startDate)
    , offset = step
    , dates = []
    ;
  Logger.log(holidays);
  if(step === 2 && startDate.getDay() === 5) {
    offset = 5;
  }
  forEachRows(rows, startIndex, function(row, i, l) {
    for(var day = -1;;) {
      date = getDateByAddingDate(startDate, offset);
      day = date.getDay();
      if(step === 2 && day === 5) {
        if(
          isInHolidays(getDateByAddingDate(date, 3), holidays) ||
          isInHolidays(getDateByAddingDate(date, 4), holidays)
        ) {
          // QA Deploy on Fridays if next Monday or Tuesday is holiday
          offset += 5;
          break;
        } else {
          // Do not deploy on Fridays
          offset += 3;
        }
      } else {
        offset += step;
        if(!isDateToSkip(date, holidays)) break;
      }
    }
    dates.push([date]);
  });
  return dates;
};

function isDateToSkip(date, holidays) {
  var day = date.getDay()
    , month = date.getMonth()
    , dateNum = date.getDate()
    ;
  return day === 0 || day === 6 || isInHolidays(date, holidays) ||
  (month === 11 && dateNum >= 27) || // nenmatsu
  (month === 0 && dateNum <=4) // nenshi
  ;
};

function isInHolidays(date, holidays) {
  return holidays.indexOf(getYMDFormattedDate(date)) !== -1;
};

var settings = null
  , sheetNames = null
  ;

function getSetting(name, key) {
  if(!settings) {
    var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("_Settings")
      , range = sheet.getRange("A:C")
      ;
    settings = range.getValues();
    sheetNames = [];
    for(var i = 0, n; i < settings.length; i++) {
      n = settings[i][0];
      if(n && !/^_/.test(n)) sheetNames.push(n);
    }
  }
  var legend = settings[0]
    , x = legend.indexOf(key)
    , y = sheetNames.indexOf(name)
    ;
  return x >= 0 && y >= 0 ? settings[y][x] : undefined;
};

function getStepByName(name) {
  return getSetting(name, "Step");
};

function getCalendarByName(name) {
  var id = getSetting(name, "Calendar ID");
  return CalendarApp.getCalendarById(id);
}

//
// Menu actions
//

function fillInNextInterval() {
  var sheet = SpreadsheetApp.getActiveSheet()
    , rows = sheet.getDataRange()
    , lastDate = getLastDate(rows)
    , isLast = lastDate.isLast
    , startIndex = isLast ? 0 : lastDate.index + 1
    , notation = "B" + (startIndex + 1) + ":" + "B" + rows.getNumRows()
    , range = sheet.getRange(notation)
    , values = getDatesToFill(rows, getStepByName(sheet.getName()))
    ;
  if(lastDate.isLast) sheet.insertColumnAfter(1);
  range.setValues(values);
};

function exportToCalendar() {
  var sheet = SpreadsheetApp.getActiveSheet()
    , rows = sheet.getDataRange()
    , name = sheet.getName()
    , step = getStepByName(name)
    , calendar = getCalendarByName(name)
    , holidays = null
    ;
  forEachRows(rows, function(row, i, l) {
    var masterName = row[0]
      , startDate = row[1]
      , date = startDate
      , count = 0
      ;
    if(!(startDate instanceof Date)) return;
    if(!holidays) holidays = getJapaneseHolidays(startDate);
    for(;;) {
      if(!isDateToSkip(date, holidays)) {
        calendar.createAllDayEvent(masterName, date);
        if(++count === step) break;
      }
      date = getDateByAddingDate(date, 1);
    }
  });
};

function fillInNextInterval2Cal() {
  fillInNextInterval();
  exportToCalendar();
}

//
// App Script Handlers
//

function onOpen() {
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet()
    , entries = [
        {
          name : "Fill in next interval",
          functionName : "fillInNextInterval2Cal"
        }
      ]
    ;
  spreadsheet.addMenu("Masters2Cal", entries);
};

初回実行時、認証ダイアログがでてくるので、権限を許可して下さい。

所感

Google App Script を初めて触りましたが、ログがリアルタイムに確認できなかったり、API がサービス全ての機能をカバーしているわけではなく、 使い辛い点もありましたが、Web 画面でサクっと書いたロジックでツールを実装できるのは魅力的だな、と思いました。

TODOs

  • 全自動化をする。 (Hubot から実行?)
  • 重複管理に対応する。
  • 当番交代に対応する。
  • 複数の休暇カレンダーに対応する。(現時点で年末年始がハードコーディングになっている。)
comments powered by Disqus