📅

Google Apps Scriptでカレンダーから勤怠管理表を自動生成する

に公開

TL;DR

  • Googleカレンダーの予定を元に、スプレッドシートの勤怠管理表を自動生成するスクリプトを作った
  • 毎月30分の手作業が、1クリック3秒で完了するようになった
  • Google Apps Script だけで完結、追加コスト0円

背景:毎月の勤怠報告が面倒すぎた

業務委託やフリーランスで働いていると、毎月の稼働時間報告が必要になることがあります。

私の場合、以下のような運用をしていました:

  1. 日々の作業:Googleカレンダーに作業予定を登録
  2. 月末の作業:カレンダーを見ながら、スプレッドシートの勤怠表に手動で転記

この「月末の転記作業」が地味に面倒。日付、曜日、開始時間、終了時間、作業時間、備考...を1日ずつ入力していく作業は、30分くらいかかることも。

「カレンダーにデータあるんだから、自動で転記できるでしょ」 と思い、Google Apps Script で自動化しました。


完成したもの

スプレッドシートのメニューに「勤怠集計」が追加され、クリックするだけで:

  1. 指定月のカレンダー予定を取得
  2. 特定のプレフィックス(例:「MTG」「開発」など)で始まる予定をフィルタリング
  3. スプレッドシートに日付・曜日・開始/終了時間・作業時間・備考を自動入力
  4. 合計時間・実働日数も自動計算

手作業30分 → ボタン1クリック になりました。


実装のポイント

1. カレンダーからイベントを取得

function getMonthlyEvents(year, month) {
  const calendar = CalendarApp.getDefaultCalendar();

  // 月の開始日と終了日
  const startDate = new Date(year, month - 1, 1, 0, 0, 0);
  const endDate = new Date(year, month, 0, 23, 59, 59);

  // イベント取得
  const events = calendar.getEvents(startDate, endDate);

  // 特定のプレフィックスでフィルタリング
  const PREFIX = "開発"; // ← 自分の用途に合わせて変更
  return events.filter((event) => event.getTitle().startsWith(PREFIX));
}

CalendarApp.getDefaultCalendar() でデフォルトカレンダーを取得し、getEvents() で指定期間のイベントを取得します。

2. 日付ごとにグループ化

同じ日に複数の予定がある場合の処理が必要です:

// 日付ごとにイベントをグループ化
const eventsByDate = {};
events.forEach((event) => {
  const start = event.getStartTime();
  const dateKey = `${start.getFullYear()}-${start.getMonth()}-${start.getDate()}`;

  if (!eventsByDate[dateKey]) {
    eventsByDate[dateKey] = [];
  }
  eventsByDate[dateKey].push(event);
});

3. 同日複数予定の表示

1日に複数の予定がある場合、備考欄に改行区切りで表示すると見やすくなります。

例えば、朝に1時間のMTG、夜に2時間の作業があった場合:

  • 開始/終了時間を単純に表示すると「9:00〜21:00」となり実態と合わない
  • 備考欄に詳細を書くことで正確な記録になる
if (dayEvents.length > 1) {
  // 複数予定:時間帯も含める
  const details = dayEvents.map((event) => {
    const start = event.getStartTime();
    const end = event.getEndTime();
    return `${event.getTitle()} (${formatTime(start)}-${formatTime(end)})`;
  });
  note = details.join("\n");
} else {
  // 単一予定:タイトルのみ
  note = dayEvents[0].getTitle();
}

出力イメージ:

開発MTG (9:00-10:00)
開発作業 (19:00-21:00)

4. スプレッドシートへの書き込み

sheet.getRange(行, 列).setValue(値) で1セルずつ書き込みます:

const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
const row = 2; // 書き込む行(例:2行目)

sheet.getRange(row, 1).setValue("12/1"); // A列: 日付
sheet.getRange(row, 2).setValue("月"); // B列: 曜日
sheet.getRange(row, 3).setValue("9:00"); // C列: 開始時間
sheet.getRange(row, 4).setValue("18:00"); // D列: 終了時間
sheet.getRange(row, 5).setValue("8:00"); // E列: 作業時間
sheet.getRange(row, 6).setValue("開発作業"); // F列: 備考

5. カスタムメニューの追加

スプレッドシートを開いたときにメニューを追加します:

function onOpen() {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu("勤怠集計")
    .addItem("今月を集計", "collectCurrentMonth")
    .addItem("先月を集計", "collectLastMonth")
    .addItem("月を指定して集計...", "collectSpecificMonth")
    .addToUi();
}

全体のコード

コードを表示(クリックで展開)
// ========== 設定 ==========
const CONFIG = {
  eventPrefix: "開発", // この文字列で始まる予定を取得
  dataStartRow: 2, // データ開始行(ヘッダーの次)
  columns: {
    date: 1, // A列: 日付
    dayOfWeek: 2, // B列: 曜日
    startTime: 3, // C列: 開始時間
    endTime: 4, // D列: 終了時間
    hours: 5, // E列: 作業時間
    note: 6, // F列: 備考
  },
};

const DAY_NAMES = ["日", "月", "火", "水", "木", "金", "土"];

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu("勤怠集計")
    .addItem("今月を集計", "collectCurrentMonth")
    .addItem("先月を集計", "collectLastMonth")
    .addItem("月を指定...", "collectSpecificMonth")
    .addToUi();
}

function collectCurrentMonth() {
  const now = new Date();
  collectMonthlyHours(now.getFullYear(), now.getMonth() + 1);
}

function collectLastMonth() {
  const now = new Date();
  let year = now.getFullYear();
  let month = now.getMonth();
  if (month === 0) {
    year--;
    month = 12;
  }
  collectMonthlyHours(year, month);
}

function collectSpecificMonth() {
  const ui = SpreadsheetApp.getUi();
  const response = ui.prompt("集計する年月を入力(例: 2025-01)");
  if (response.getSelectedButton() === ui.Button.OK) {
    const [year, month] = response.getResponseText().split("-").map(Number);
    if (year && month) collectMonthlyHours(year, month);
  }
}

function collectMonthlyHours(year, month) {
  const ui = SpreadsheetApp.getUi();

  try {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
    const calendar = CalendarApp.getDefaultCalendar();

    const startDate = new Date(year, month - 1, 1);
    const endDate = new Date(year, month, 0, 23, 59, 59);
    const daysInMonth = endDate.getDate();

    // イベント取得 & フィルタリング
    const events = calendar
      .getEvents(startDate, endDate)
      .filter((e) => e.getTitle().startsWith(CONFIG.eventPrefix));

    // 日付ごとにグループ化
    const eventsByDate = {};
    events.forEach((event) => {
      const d = event.getStartTime();
      const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
      if (!eventsByDate[key]) eventsByDate[key] = [];
      eventsByDate[key].push(event);
    });

    // データクリア
    sheet.getRange(CONFIG.dataStartRow, 1, daysInMonth, 6).clearContent();

    let totalHours = 0,
      workDays = 0;

    // 月の全日付をループ
    for (let day = 1; day <= daysInMonth; day++) {
      const date = new Date(year, month - 1, day);
      const key = `${year}-${month - 1}-${day}`;
      const row = CONFIG.dataStartRow + day - 1;
      const col = CONFIG.columns;
      const dayEvents = eventsByDate[key] || [];

      sheet.getRange(row, col.date).setValue(`${month}/${day}`);
      sheet.getRange(row, col.dayOfWeek).setValue(DAY_NAMES[date.getDay()]);

      if (dayEvents.length > 0) {
        let dayHours = 0;
        const details = [];

        dayEvents.forEach((event) => {
          const start = event.getStartTime();
          const end = event.getEndTime();
          const hours = (end - start) / (1000 * 60 * 60);
          dayHours += hours;

          if (dayEvents.length > 1) {
            details.push(
              `${event.getTitle()} (${formatTime(start)}-${formatTime(end)})`,
            );
          } else {
            details.push(event.getTitle());
          }
        });

        if (dayEvents.length === 1) {
          sheet
            .getRange(row, col.startTime)
            .setValue(formatTime(dayEvents[0].getStartTime()));
          sheet
            .getRange(row, col.endTime)
            .setValue(formatTime(dayEvents[0].getEndTime()));
        }

        sheet.getRange(row, col.hours).setValue(formatDuration(dayHours));
        sheet.getRange(row, col.note).setValue(details.join("\n"));

        totalHours += dayHours;
        workDays++;
      } else {
        sheet.getRange(row, col.hours).setValue("0:00");
      }
    }

    ui.alert(
      "集計完了",
      `${year}年${month}月\n実働: ${workDays}日\n合計: ${totalHours}時間`,
      ui.ButtonSet.OK,
    );
  } catch (error) {
    ui.alert("エラー", error.message, ui.ButtonSet.OK);
  }
}

function formatTime(date) {
  return Utilities.formatDate(date, "Asia/Tokyo", "H:mm");
}

function formatDuration(hours) {
  const h = Math.floor(hours);
  const m = Math.round((hours - h) * 60);
  return `${h}:${m.toString().padStart(2, "0")}`;
}

使い方

  1. スプレッドシートを開く
  2. 拡張機能 → Apps Script を開く
  3. 上記コードを貼り付けて保存
  4. スプレッドシートをリロード
  5. メニューに「勤怠集計」が追加される
  6. 初回実行時にカレンダーへのアクセス許可を求められるので許可

カスタマイズ

CONFIG を自分の環境に合わせて調整してください:

設定 説明
eventPrefix 取得対象の予定のプレフィックス 'MTG', '作業', '客先'
dataStartRow データ書き込み開始行 ヘッダーが1行目なら 2
columns 各データを書き込む列番号 A=1, B=2, ...

つまづきポイント

「このアプリはGoogleで確認されていません」と表示される

初回実行時に以下のような警告が表示されることがあります:

このアプリは Google で確認されていません
このアプリは、Google による確認が済んでいないため、続行すると危険性があります。

これは正常な動作です。 自作のスクリプトはGoogleの審査を受けていないため、この警告が表示されます。

対処法:

  1. 「詳細」をクリック
  2. 「〇〇(安全ではないページ)に移動」をクリック
  3. 「許可」をクリック

自分で作成したスクリプトなので、安全に実行できます。

数値が日付として表示される

スプレッドシートに数値(例:実働日数 14)を書き込むと、「1900/1/14」のように日付として表示されることがあります。

対処法:

// 数値を文字列として書き込む
sheet.getRange("A1").setValue(`${workDays}`);

テンプレートリテラルで囲むことで文字列として扱われます。

予定が取得できない

以下を確認してください:

  1. プレフィックスが正しいか: カレンダーの予定タイトルと eventPrefix が一致しているか
  2. デフォルトカレンダーか: 共有カレンダーや別アカウントのカレンダーは getDefaultCalendar() では取得できません
  3. 月の指定が正しいか: 先月や今月を間違えていないか

デバッグ用に、取得したイベント数をログ出力すると原因を特定しやすくなります:

console.log(`取得イベント数: ${events.length}`);

応用:複数シートへの書き込み

請求書シートにも同時に書き込みたい場合:

// 別シートへの書き込み
const invoiceSheet =
  SpreadsheetApp.getActiveSpreadsheet().getSheetByName("請求書");

if (invoiceSheet) {
  invoiceSheet.getRange("B5").setValue(`${month}月分`);
  invoiceSheet.getRange("D10").setValue(totalHours);
}

まとめ

  • Googleカレンダー × スプレッドシートの連携は Google Apps Script で簡単に実現できる
  • 毎月の定型作業を自動化することで、時間の節約だけでなく転記ミスも防げる
  • コードは100行程度、追加コスト0円

「毎月やってる面倒な作業」があれば、自動化できないか考えてみましょう。


参考

Discussion