GASでGoogleカレンダーから勤怠集計を自動化 月末の集計作業を月2時間削減する
月末になると、メンバーのカレンダーを1人ずつ開いて稼働時間を数え、スプレッドシートに打ち込む——この作業が毎月2〜3時間かかっているなら、Google Apps Script に丸ごと任せられます。追加費用¥0で、稼働時間と所定外時間を担当者別・日別に自動集計する方法を、動くコードと労務上の注意つきで公開します。
- Googleカレンダーの予定から、担当者別・日別の稼働時間と所定外時間をスプレッドシートへ自動出力
- 勤怠管理サービスを契約せず、Google標準機能だけで月¥0(人数が増えても費用は変わらない)
- 本記事のコードはそのままコピーして動く(月次の時間主導型トリガー+スプレッドシート書き出し)
- 集計を狂わせる3つの罠(終日予定・月またぎ予定・タイムゾーン)への対処を具体的に解説
- ⚠️ カレンダーは「予定」であって実績ではありません。労働基準法上の客観的な記録の代わりにはならない線引きも明示します
編集長の見解:このレシピの価値は「勤怠管理システムを置き換えること」ではありません。置き換えを狙うと必ず労務リスクに突き当たります。狙うべきは、月末に人手で数えている「稼働時間の下書き」を機械に作らせ、経営者は差分の確認だけに集中する状態です。ゼロから数える作業と、出てきた数字を確認する作業では、かかる時間が一桁違います。
🔍 「朝会通知」ではなく「勤怠集計」を自動化する理由
カレンダー連携の自動化というと、まず思いつくのは予定のリマインド通知です。当サイトでもGASでGoogleカレンダー予定をSlackへ朝会リマインドするレシピを公開しています。ただし通知の自動化は「当日の情報共有」を助けるだけで、月末の集計作業には効きません。
本記事が扱うのは、その逆側です。すでにカレンダーに入っている予定を元データとして、月に1回まとめて集計する——ここだけに絞ります。新しく打刻の仕組みを作る話ではなく、日々の見張りやリアルタイム通知の話でもありません。既存の予定を資産として使い切るレシピだと考えてください。
集計作業がしんどいのは、次の3つが同時に発生するからです。
- 人数分の反復: メンバーが6人いれば、同じ作業を6回繰り返す
- 日付単位の分解: 月合計ではなく日別に出さないと、所定外時間が見えない
- 転記ミス: 目視で数えた数字を手打ちするため、検算が必要になる
いずれも人間が最も苦手で、プログラムが最も得意な処理です。次のセクションで、その全体像を確認します。
🗺 全体フロー — 月初に自動で「集計済みシート」ができる
構造はシンプルです。毎月1回スクリプトが起動し、メンバーごとのカレンダーから前月分の予定を読み取り、日別に合計してスプレッドシートへ書き出します。
設定後は毎月自動実行、人の操作は確認のみ
登場する部品は3つだけです。役割と一次情報源を整理します。
| 部品 | 役割 | 公式ドキュメント |
|---|---|---|
| 時間主導型トリガー | 毎月決まった日に関数を自動実行 | インストール可能なトリガー |
| CalendarApp サービス | 指定期間の予定を取得 | Calendar クラス リファレンス |
| SpreadsheetApp サービス | 集計結果をシートへ書き込み | SpreadsheetApp リファレンス |
この3つをつなぐコードは、次のセクションのものをコピーするだけで動きます。
💰 必要なものと費用の目安
追加の支出は発生しません。用意するものを表で確認してください。
| 必要なもの | 役割 | 費用の目安 |
|---|---|---|
| Googleアカウント(無料 or Workspace) | カレンダー・GAS の実行 | 無料(Workspace は既存契約で可) |
| Googleスプレッドシート | 集計結果の出力先 | 無料 |
| メンバーの共有カレンダー | 稼働時間の元データ | 無料 |
| 集計対象メンバーの閲覧権限 | 予定の読み取り | 無料(共有設定のみ) |
💡 編集部の判断材料:すでに勤怠管理サービスを契約しているなら、乗り換える必要はまったくありません。このレシピが向くのは「従業員数名で専用サービスの月額が割に合わない」「業務委託メンバーの稼働時間を請求根拠として把握したい」「プロジェクト別の工数を知りたい」ケースです。逆に打刻・有給管理・給与計算まで必要なら、専用サービスのほうが確実に安く済みます。
判断がついたら、構築に進みます。
🛠 設定手順 — 約40分で完成
作業は「シートとカレンダーの準備」「コード貼り付け」「月次トリガー設定」の3段階です。プログラミングの知識は不要です。
/d/ と /edit の間)を控えます。aggregateMonthlyWorkHours を1回実行し、前月分のシートが生成されるか確認します。STEP 7 の画面の文言は、Google 側の更新で変わることがあります。ラベルが見当たらない場合は、次の1行を関数として追加して一度だけ実行すれば、同じ月次トリガーがコードから作れます(onMonthDay と atHour は Apps Script の公式リファレンスに載っている正式なメソッドです)。
function createMonthlyTrigger() {
ScriptApp.newTrigger('aggregateMonthlyWorkHours')
.timeBased()
.onMonthDay(1) // 毎月1日
.atHour(4) // 午前4時台に実行
.create();
}
貼り付けるコード(そのまま動きます)
以下を Apps Script エディタに貼り付けてください。トリガーから呼ばれる aggregateMonthlyWorkHours が本体です。
/**
* Googleカレンダー → 勤怠集計(稼働時間・所定外時間)をスプレッドシートへ出力
* トリガー: 時間主導型(月ベースのタイマー、毎月1日)で
* aggregateMonthlyWorkHours を実行
*/
// 1日の所定労働時間(時間)。これを超えた分を「所定外」として集計します。
const STANDARD_HOURS_PER_DAY = 8;
// タイトルにこれらを含む予定は稼働時間から除外します。
// 部分一致で判定するため、短すぎる語(「仮」1文字など)は
// 意図しない予定まで除外してしまいます。2文字以上を推奨します。
const EXCLUDE_KEYWORDS = ['休憩', 'ランチ', '私用', '仮予約'];
function aggregateMonthlyWorkHours() {
const props = PropertiesService.getScriptProperties();
const sheetId = props.getProperty('TARGET_SHEET_ID');
const memberJson = props.getProperty('MEMBER_CALENDARS');
if (!sheetId || !memberJson) {
throw new Error('TARGET_SHEET_ID と MEMBER_CALENDARS を先に設定してください');
}
const members = JSON.parse(memberJson);
const period = getPreviousMonthRange_();
const rows = [];
members.forEach(function (member) {
const calendar = CalendarApp.getCalendarById(member.calendarId);
if (!calendar) {
console.warn('カレンダーを取得できません: ' + member.calendarId);
return;
}
// 注意: getEvents は「期間内に始まる/期間内に終わる/期間をまたぐ」
// 予定をすべて返すため、後段で期間内に切り詰めます。
const events = calendar.getEvents(period.start, period.end);
const dailyMinutes = {};
events.forEach(function (ev) {
if (ev.isAllDayEvent()) return; // 終日予定は稼働時間に含めない
if (isExcluded_(ev.getTitle())) return; // 休憩などを除外
const startMs = Math.max(ev.getStartTime().getTime(), period.start.getTime());
const endMs = Math.min(ev.getEndTime().getTime(), period.end.getTime());
const minutes = (endMs - startMs) / 60000;
if (minutes <= 0) return;
const dayKey = Utilities.formatDate(new Date(startMs), scriptTz_(), 'yyyy-MM-dd');
dailyMinutes[dayKey] = (dailyMinutes[dayKey] || 0) + minutes;
});
Object.keys(dailyMinutes).sort().forEach(function (day) {
const hours = dailyMinutes[day] / 60;
const overtime = Math.max(0, hours - STANDARD_HOURS_PER_DAY);
rows.push([day, member.name, round2_(hours), round2_(overtime)]);
});
});
writeToSheet_(sheetId, period.label, rows);
}
/**
* スクリプトのタイムゾーンを返す。
* プロジェクト設定の「タイムゾーン」(appsscript.json の timeZone)が正本です。
* ここを固定文字列にすると、月境界の計算と日付キーの基準がずれます。
*/
function scriptTz_() {
return Session.getScriptTimeZone();
}
/** 前月の 1 日 0:00 から当月 1 日 0:00 までを返す */
function getPreviousMonthRange_() {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const end = new Date(now.getFullYear(), now.getMonth(), 1);
const label = Utilities.formatDate(start, scriptTz_(), 'yyyy-MM');
return { start: start, end: end, label: label };
}
function isExcluded_(title) {
return EXCLUDE_KEYWORDS.some(function (keyword) {
return title.indexOf(keyword) !== -1;
});
}
function round2_(num) {
return Math.round(num * 100) / 100;
}
function writeToSheet_(sheetId, label, rows) {
const ss = SpreadsheetApp.openById(sheetId);
const sheet = ss.getSheetByName(label) || ss.insertSheet(label);
sheet.clearContents();
sheet.appendRow(['日付', '担当者', '稼働時間(h)', '所定外(h)']);
if (rows.length > 0) {
sheet.getRange(2, 1, rows.length, rows[0].length).setValues(rows);
}
}
シート ID とメンバー一覧の登録(コードに直書きしない)
STEP 1・2 で控えた値は、コードに直接書かず「スクリプトプロパティ」に保存します。従業員の氏名やカレンダーID は個人情報にあたるため、コードを共有した相手に見えてしまう状態を避けるのが安全です。
- Apps Script エディタ左の**歯車アイコン(プロジェクト設定)**を開く
- 最下部の「スクリプト プロパティ」で「スクリプト プロパティを追加」をクリック
- プロパティ名
TARGET_SHEET_IDに、STEP 1 で控えたスプレッドシートの ID を入力 - プロパティ名
MEMBER_CALENDARSに、下記の形式で対象メンバーを入力
[
{ "name": "山田", "calendarId": "yamada@example.com" },
{ "name": "佐藤", "calendarId": "sato@example.com" }
]
保存したら、必ず STEP 6 の手動実行でテストしてください。次のセクションが、この記事でいちばん重要な部分です。
⚠️ 集計を静かに狂わせる3つの罠
このレシピが厄介なのは、設定を間違えてもエラーが出ず、それらしい数字が出てしまう点です。編集部が特に注意すべきと考える3つを挙げます。
⚠️ 罠1:終日予定を含めると1日24時間として計上される。Googleカレンダーの終日予定は、終了日が「翌日の0時」として扱われます。Apps Script の CalendarEvent リファレンスは、終日予定の日付を返す getAllDayEndDate() の説明で、返る値が「予定が終わる日の翌日の始まりの0時(スクリプトのタイムゾーン基準)」であると明記しています。つまり1日だけの終日予定でも終了時刻マイナス開始時刻が24時間になり、有給や出張の終日予定を混ぜたまま集計すると稼働時間が跳ね上がります。上記コードが isAllDayEvent() で除外しているのはこのためです。
⚠️ 罠2:月をまたぐ予定が二重計上される。期間指定で予定を取る仕組みは、Googleカレンダー API の絞り込み条件を見ると理解できます。API の timeMin は「予定の終了時刻の下限」、timeMax は「予定の開始時刻の上限」として定義されています。つまり期間に少しでも重なる予定は返ってくる、ということです。切り詰めずに終了時刻マイナス開始時刻で計算すると、月末から翌月にかかる予定の時間が前月分にも翌月分にも入ります。上記コードで Math.max と Math.min により期間内へ切り詰めているのは、この二重計上を防ぐためです。
なお切り詰めた時間は、その予定の開始日に全額まとめて計上されます。日をまたぐ予定(22時から翌2時など)は、4時間すべてが開始日の分として記録されます。月合計は正しく出ますが、夜勤や深夜作業がある事業所では日別の内訳が実態とずれるため、その点は割り切って使ってください。
3つ目はタイムゾーンです。Apps Script のプロジェクトにはタイムゾーン設定があり(appsscript.json の timeZone フィールドに Asia/Tokyo のような形式で保存されます)、new Date(年, 月, 1) はこの設定に従って解釈されます。プロジェクトが日本標準時以外になっていると、月の境界が数時間ずれて集計対象が変わります。上記コードが日付の書式変換にも Session.getScriptTimeZone() を使い、'Asia/Tokyo' と直接書かないのは、月境界と日付キーの基準を1つに揃えるためです。STEP 4 で設定を確認するのは、この理由からです。
| 症状 | 起こりやすい原因 | 編集部の対策 |
|---|---|---|
| 特定の人だけ稼働時間が異常に多い | 終日予定(有給・出張)が混入 | isAllDayEvent() の除外が効いているか確認 |
| 月初・月末だけ数字が合わない | 月またぎ予定の二重計上、またはタイムゾーンのずれ | 期間の切り詰め処理とプロジェクト設定のタイムゾーンを確認 |
| 夜勤の人の日別内訳だけ合わない | 日をまたぐ予定が開始日に全額計上される仕様 | 月合計は正しいので、日別が要る場合は予定を日ごとに分けて入力 |
| 特定メンバーが1行も出ない | カレンダーの閲覧権限がない | 共有設定を確認(権限がないと静かに空になる) |
| 予定はあるのに時間が短い | 除外キーワードに引っかかっている | EXCLUDE_KEYWORDS の語を見直す |
| 実績と乖離している | そもそも予定どおりに動いていない | 後述のとおり、実績記録の代わりにはなりません |
なお Apps Script には実行時間の上限があり、1回の実行は6分、トリガー経由の合計実行時間は無料アカウントで1日90分、Workspace アカウントで1日6時間です。作れるトリガーの数も1ユーザー1スクリプトあたり20個までです(割り当てと制限)。数名から数十名規模の月次集計であれば、いずれの上限にも届くことはまずありません。
⚖️ 労務上の位置づけ — ここを外すと危険
ここが本記事で最も重要な線引きです。カレンダーの予定は「実績の記録」ではありません。
厚生労働省の「労働時間の適正な把握のために使用者が講ずべき措置に関するガイドライン」(平成29年1月20日策定)は、始業・終業時刻の確認と記録の原則的な方法を次の2つに限定しています(ガイドライン本文 PDF)。
- ア 使用者が、自ら現認することにより確認し、適正に記録すること
- イ タイムカード、ICカード、パソコンの使用時間の記録等の客観的な記録を基礎として確認し、適正に記録すること
カレンダーに入っている「16時から17時 打ち合わせ」は、その時間に本当に働いたことの記録ではなく、働く予定です。したがって、これをそのまま始業・終業時刻の記録として使うのは、上記のアともイとも異なります。
⚠️ やってはいけない使い方:このスクリプトの出力を、そのまま賃金台帳の労働時間数の根拠にすること。労働基準法第108条および同法施行規則第54条により、賃金台帳には労働日数・労働時間数・時間外労働時間数などを適正に記入する義務があり、同法第120条により、記入していない場合や故意に虚偽の労働時間数を記入した場合は30万円以下の罰金の対象です。カレンダー由来の推計値を実績として記入する運用は、この義務と正面から衝突します。
では何に使えるのか。編集部としては、次の3つを推奨します。
- 自己申告の突合材料: 上記ガイドラインは、自己申告制による場合に「必要に応じて実態調査を実施し、所要の労働時間の補正をすること」を求めています。申告値とカレンダー由来の推計値を並べれば、乖離のある月を機械的に見つけられます
- プロジェクト別工数の把握: 誰がどの案件にどれだけ時間を割いたかは、労務ではなく経営判断の材料です。ここはカレンダーの得意分野です
- 業務委託先への請求根拠の下書き: 労働基準法上の労働者でない委託先の稼働報告なら、実態に即している限り有力な下書きになります
💡 編集部メモ:労働時間の記録に関する書類の保存年数にも注意が必要です。労働基準法第109条は「五年間保存しなければならない」と定めていますが、附則第143条第1項により「当分の間」は三年間と読み替えられます。手元の記事や解説で3年と5年が混在しているのはこのためで、現時点の実務上の下限は3年です。スプレッドシートで残す場合も、月ごとのシートを消さずに積み上げる運用にしておくのが安全です。
あわせて、労働安全衛生法第66条の8の3は、医師による面接指導を実施するため、厚生労働省令で定める方法により労働者の労働時間の状況を把握することを事業者に義務づけています。長時間労働の把握は「やったほうがよいこと」ではなく法律上の義務であり、この記事の仕組みはその義務を果たすための補助であって代替ではない、と理解してください。
🚀 経営者はこの40分をどう回収するか
編集部の想定シナリオを1つ挙げます。従業員6名の制作会社で、管理者が毎月末にメンバー全員のカレンダーを開いて稼働時間を数え、スプレッドシートへ転記しているケースです(実在の企業の事例ではありません)。
- 導入前: 1人あたり約25分(数える15分+転記と検算10分)、6名で月約150分
- 導入後: 出力済みシートを開き、明らかにおかしい行だけ確認して手直しする作業に変わり、月約30分
- 効果: 月あたり約120分、年間で約24時間の削減。かかった費用は¥0、かかった時間は初回の40分だけ
基本形が動いたら、次の応用も同じコードの延長で組めます。
- 案件別の集計: 予定タイトルの先頭に案件コードを入れる運用にし、
dayKeyの代わりに案件コードで合計する - 限度時間との突合: 労働基準法第36条第4項が定める限度時間(原則として1か月45時間・1年360時間)を集計シートに基準列として置き、超えそうな月を色付けする
- 月次レポートの自動送信: 集計後にシートの URL をメールで自分宛てに送り、確認を習慣化する
当サイトでは、GAS とスプレッドシートを組み合わせた自動化を複数紹介しています。あわせて読むと設計の引き出しが増えます。
- GASでGoogleカレンダー予定をSlackへ朝会リマインドするレシピ
- GASでGmailの内容をスプレッドシートへ自動蓄積するレシピ
- Make.comでカレンダー予定の前日リマインドを自動化するレシピ
- GASでGoogleドライブを自動バックアップするレシピ
編集部のまとめ:このレシピの本質は「数える作業を機械に、判断する作業を人に」振り分け直すことです。カレンダーは法定の労働時間記録にはなりませんが、月末に人が電卓を叩く下書きとしては十分に使えます。まず前月分を1回出力してみて、手集計の結果とどれくらい合うかを確かめるところから始めてください。その乖離こそが、自社の予定と実績のズレを映した最初のデータになります。
出典・参考情報
- Google Apps Script 公式サイト
- Apps Script Calendar クラス リファレンス(getEvents の仕様)
- Apps Script CalendarEvent リファレンス(終日予定の getAllDayEndDate)
- Google Calendar API events.list(timeMin / timeMax の定義)
- Apps Script SpreadsheetApp リファレンス
- Apps Script インストール可能なトリガー
- Apps Script ClockTriggerBuilder リファレンス(onMonthDay / atHour)
- Apps Script の割り当てと制限
- 厚生労働省 労働時間の適正な把握のために使用者が講ずべき措置に関するガイドライン
- 同ガイドライン本文(PDF)
- 労働基準法(e-Gov 法令検索)
- 労働安全衛生法(e-Gov 法令検索)
- Googleカレンダー 公式サイト
Mira / AI経営ラボ 編集長