Javaの学習として、Google Calendar の予定を取得し、勤務開始時間・終了時間・休憩時間を計算して、Google Sheets に書き込むCLIアプリを作りました。
この記事では、Google API の細かい使い方や Java の文法そのものではなく、実装を進めるうえで大事だと感じた「タスク分解」について、自分なりに整理してみます。
最初から完成形だけを見ると、
- Google Calendar API に接続する
- Google Sheets API に接続する
- OAuth認証を通す
- カレンダー予定を対象月で取得する
- 勤務開始・終了・休憩時間を計算する
- スプレッドシートの該当セルに書き込む
- 過去月の上書きを防ぐ
など、やることが多く見えます。
さらに、実際の勤怠では残業があったり、フレックスで勤務時間が日によって変わったり、休憩を30分ずつ2回に分けて取ることもあります。
単純に「開始と終了を入れるだけ」ではなく、カレンダーの予定から勤務時間をどう解釈するかも考える必要がありました。
このように、最初から全部をまとめて考えると複雑に見えます。
しかし、ひとつずつ確認できる単位に分解すると、実装は着実に進められます。
作ったもの
作ったのは、Google Calendar の予定をもとに、対象月の勤怠情報を Google Sheets に自動入力するコマンドラインアプリです。
私たち(株式会社add more)は工数管理と、作業の予定と見積もりのためにメインカレンダーとは別に作業時間カレンダーを作成し運用しています。
今回は2026/6/1(月)から2026/6/5(金)までこのようにサンプルとして入力しました。
実行例はこちらです。
引数で年と月を渡せるように実装しています。
./gradlew run --args="2026-06"
すると以下のように自動で勤怠確認をしてくださる方へ報告するスプレッドシートに転記されていくという仕組みです。
出力例です。
勤怠入力スクリプト開始
対象年月: 2026-06
対象シート: 6月
スプレッドシート: 【検証】勤務時間申請
2026-06-01 6月!C5:E5 開始:09:30, 終了:19:00, 休憩:01:00
2026-06-02 6月!C6:E6 開始:09:30, 終了:19:30, 休憩:01:00
基本的な計算ルールはシンプルです。
- その日の最初の予定の開始時刻を、業務開始時間にする
- その日の最後の予定の終了時刻を、業務終了時間にする
- 予定と予定の間にある空白時間を、休憩時間として合計する
- 予定がない日は、Sheetsへの書き込みをスキップする
たとえば、以下のような予定がある場合、
09:30 - 12:00
13:00 - 18:30
計算結果はこうなります。
開始: 09:30
終了: 18:30
休憩: 01:00
また、過去月を指定した場合は、すでに報告済みの勤怠を誤って上書きしないように、まずドライランとして書き込み予定内容だけを表示し、実際に書き込むか確認する仕様にしました。
./gradlew run --args="2026-05"
最初に感じた難しさ
最初から「Google Calendar の予定を取得して、勤怠を計算して、Google Sheets に書き込む」と考えると、かなり大きな実装に見えます。
特に難しく感じたのは、複数の問題が同時に出てくることです。
たとえば、Calendar API の疎通ができていない段階で、勤怠計算ロジックまで考え始めると、どこで詰まっているのか分からなくなります。
Sheets API の書き込みも同じです。
- 認証が失敗しているのか
- スプレッドシートIDが間違っているのか
- シートタブ名が違うのか
- セル範囲の指定が間違っているのか
- 値の型が合っていないのか
これらを一気に扱うと、エラーの原因を切り分けるのが難しくなります。
そのため、いきなり完成形を目指すのではなく、まずは「次に確認できる最小単位」までタスクを細かくしました。
いきなり実装せず、まずタスクを分解した
今回意識したのは、完成形から逆算しつつも、実装単位はかなり小さくすることです。
たとえば、最初から「Calendarの予定を取得して、勤怠を計算して、Sheetsに1ヶ月分を書き込む」と考えると、かなり大きな実装に見えます。
そこで、実際には以下のようにタスクを分解しました。
| フェーズ | タスク例 |
|---|---|
| 準備 | GCPプロジェクト作成 / Calendar API有効化 / Sheets API有効化 / OAuth認証情報作成 |
| プロジェクト作成 | Gradleプロジェクト作成 / ディレクトリ作成 / .gitignore 作成 / 最小プログラムで起動確認 |
| Calendar API | Calendar API疎通確認 / 対象年月の引数受け取り / 月初・月末の算出 / 対象月の予定一覧取得 |
| 勤怠計算 | WorkPeriod 作成 / DailyAttendance 作成 / テストケース作成 / DailyAttendanceCalculator 実装 |
| Sheets API | Sheets API疎通確認 / 1セルに固定値を書き込み / 1日分の固定値を書き込み / 計算結果をSheetsへ書き込み |
| 月次処理 | 日付から行番号を計算 / シートタブ名の生成と存在確認 / 1日〜末日までループして書き込み |
| 運用対策 | ログ整理 / 過去月指定時の確認表示 / README作成 / application.properties.example 作成 |
特に効果があったのは、Sheetsへの書き込みを以下のように段階的に分けたことです。
Sheets APIに接続できるか確認する
↓
1セルに固定値を書き込む
↓
1日分の固定値を書き込む
↓
計算した1日分の勤怠を書き込む
↓
対象月すべての勤怠を書き込む
このように段階を分けると、失敗したときに原因を絞りやすくなります。
たとえば、1セルに固定値を書き込めないなら、Sheets APIの認証、スプレッドシートID、シート名、セル範囲の指定を確認すればよいと分かります。
1セルは書けるけれど1日分が書けないなら、範囲指定や値の渡し方が悪いのだと疑えます。
1日分は書けるけれど1ヶ月分で失敗するなら、ループ処理や日付ごとのデータ整理、行番号の計算あたりを確認すればよいということになります。
「Sheets APIを使う」では大きすぎますが、「1セルに固定値を書き込む」なら成功・失敗がはっきりします。
小さく動くものを積み上げることで、「ここまでは動いている」と確認しながら、着実に実装を進められました。
タスク分解の全体像
最終的には、以下のようなフェーズに分けて進めました。
| フェーズ | 内容 |
|---|---|
| フェーズ1 | Google APIに接続する |
| フェーズ2 | カレンダー予定を対象月で取得する |
| フェーズ3 | 勤怠計算ロジックを作る |
| フェーズ4 | Sheetsに書き込む |
| フェーズ5 | 実運用で危ないところを潰す |
実際には、さらに細かいタスクに分けています。
たとえば Calendar API 周りでは、
- Google Cloud Consoleでプロジェクトを作成する
- Google Calendar APIを有効化する
- OAuthクライアントIDを作成する
- 認証情報ファイルを配置する
- Calendar APIに接続する
- コマンド引数で対象年月を受け取る
- 月初・翌月月初を算出する
- 対象月の予定一覧を取得する
という単位にして考えました。
一つひとつは小さいですが、順番に確認できるため、実装の見通しが良くなります。
フェーズ1: Google APIに接続する
最初は、Google Calendar API と Google Sheets API の疎通確認から始めました。
この段階では、勤怠計算はしません。
まずは、以下だけを確認します。
- OAuth認証が通るか
- Calendar APIから予定を取得できるか
- Sheets APIからスプレッドシート名を取得できるか
この段階では「業務ロジックを混ぜない」ように実装を進めてます。
Calendar APIに接続できるか確認するだけなら、予定を10件表示できれば十分です。
Sheets APIに接続できるか確認するだけなら、スプレッドシート名を表示できれば十分です。
ここでいきなり勤怠計算やセル書き込みまで混ぜると、問題が起きたときに原因が分かりにくくなります。
実際、認証スコープを追加したあとに、既存のトークンでは権限が足りず、tokens/ を削除して再認証する必要がありました。
rm -rf tokens/*
touch tokens/.gitkeep
こういう問題も、疎通確認を小さく分けていたので切り分けしやすかったです。
フェーズ2: カレンダー予定を対象月で取得する
次に、対象年月をコマンド引数で受け取れるようにしました。
./gradlew run --args="2026-06"
引数なしの場合は、実行月を対象にします。
ここで YearMonth を使って、対象月の月初と翌月月初を算出しました。
2026-06
↓
2026-06-01T00:00
2026-07-01T00:00
月末を 2026-06-30T23:59 のように扱うより、以下のように考える方が扱いやすいと感じました。
2026-06-01T00:00 以上
2026-07-01T00:00 未満
その後、Calendar APIにこの期間を渡して、対象月の予定だけを取得するようにしました。
ここでも、最初は計算せずに予定を表示するだけにしました。
2026-06-01
09:30 - 12:00 task1
13:00 - 18:30 task2
この出力は最終的に使うものではありませんが、Calendar API から予定の開始・終了を正しく取得できているか確認するために出していました。
フェーズ3: 勤怠計算ロジックを作る
次に、勤怠計算ロジックを作りました。
ここでは、Google APIとは切り離して、純粋なJavaのロジックとして実装しました。
作った主なモデルは以下です。
public record WorkPeriod(
LocalDateTime start,
LocalDateTime end
) { }
WorkPeriod は、カレンダー予定1件分を表します。
public record DailyAttendance(
LocalTime startTime,
LocalTime endTime,
Duration breakTime
) { }
DailyAttendance は、1日分の勤怠計算結果を表します。
計算ロジックは DailyAttendanceCalculator に持たせました。
ここでは先にテストを書きました。
テストケースは以下です。
ケース1: 勤務予定が2本あり、間に1時間の空白がある場合
09:30 - 12:00
13:00 - 18:30
期待結果: 開始 09:30 / 終了 18:30 / 休憩 01:00
ケース2: 勤務予定が1本だけの場合
09:30 - 18:30
期待結果: 開始 09:30 / 終了 18:30 / 休憩 00:00
ケース3: 勤務予定が連続している場合
09:00 - 12:00
12:00 - 18:00
期待結果: 開始 09:00 / 終了 18:00 / 休憩 00:00
ケース4: 空白時間が複数ある場合
09:00 - 12:00
13:00 - 15:00
16:00 - 18:00
期待結果: 開始 09:00 / 終了 18:00 / 休憩 02:00
このように先に期待結果を決めると、実装すべき仕様が明確になります。
また、勤怠計算をGoogle APIに依存しない形にしたことで、以下のようにテストだけで確認できるようになります。
./gradlew test
フェーズ4: Sheetsに書き込む
Sheetsへの書き込みも、いきなり計算結果を書き込むのではなく、段階的に進めました。
最初は、1セルに固定値を書き込みました。
6月!C5 = 09:30
次に、1日分の固定値を書き込みました。
6月!C5:E5 = 09:30, 18:30, 01:00
その後は、計算した1日分の勤怠を書き込む処理に置き換えました。
最終的には対象月の1日から末日まで処理し、予定がある日だけ Sheets に反映する形にしています。
また、実行時には書き込み先のシート名とセル範囲もログに出すようにしました。
2026-06-01 6月!C5:E5 開始:09:30, 終了:19:00, 休憩:01:00
2026-06-02 6月!C6:E6 開始:09:30, 終了:19:30, 休憩:01:00
ここで、日付から行番号を計算する処理も入れました。
private static int calculateRowNumber(LocalDate date) {
// TODO: 現在は「1日が5行目にある」前提で行番号を計算。
// シートの見出し行やレイアウトが変わるとズレるため、将来的にはA列の日付を検索して行番号を取得する方式も検討する。
return date.getDayOfMonth() + 4;
}
+ 4 しているのは、私が使用している勤務時間報告用のシートで、1日の入力行が5行目から始まるためです。
この実装は現在のシート構成に依存しているため、レイアウトが変わると行番号がズレる可能性があります。
本来はA列の日付を検索して、該当行を取得する方が安全だと思います。
今回は学習段階だったため、まずは固定ルールで動かし、改善点として残したうえで次に進みました。
フェーズ5: 実運用で危ないところを潰す
一通り書き込みまでできたあと、実運用で危なそうな部分を見直しました。
特に重要だったのは、過去月の扱いです。
過去月は、すでに提出済みの可能性があります。
その状態で再実行して Sheets を上書きすると、混乱の原因になります。
そこで、過去月を指定した場合は、まずドライランとして書き込み予定内容だけを表示するようにしました。
[DRY-RUN] 2026-05-07 5月!C11:E11 開始:09:30, 終了:18:30, 休憩:01:00
その後、実際に書き込むかどうかを確認し、yes と入力した場合のみ Sheets に反映します。
no、空入力、その他の入力の場合は中止します。
⚠ 過去月へ実際に書き込みます。
対象年月: 2026-05
すでに報告済みの勤怠を上書きする可能性があります。
続行する場合は yes と入力して Enter を押してください。
>
また、./gradlew run 経由で標準入力を受け取るために、build.gradle.kts に以下を追加しました。
tasks.named<JavaExec>("run") {
standardInput = System.`in`
}
これを入れないと、Javaアプリ側で Scanner(System.in) を使っても入力待ちにならず、空入力扱いで処理が進んでしまいました。
こういった運用上の安全対策は、実際に使う前に見直しておくべき部分だと感じました。
細かく分けて良かったこと
今回、タスクを細かく分けて良かったことはいくつかあります。
まず、詰まったときに原因を切り分けやすくなりました。
Calendar APIの問題なのか、勤怠計算ロジックの問題なのか、Sheets APIの問題なのかを分けて考えられます。
次に、固定値から始められたのが良かったです。
1セルに固定値を書き込む
↓
1日分の固定値を書き込む
↓
計算結果を書き込む
この順番にしたことで、Sheets APIの使い方と勤怠計算ロジックを同時に悩まずに済みました。
また、コミット単位も自然に小さくなりました。
Calendar APIの疎通確認
対象年月の決定ロジックを追加
対象月の予定一覧を取得
1日分の勤怠計算を実装
Sheets APIの疎通確認
Sheets APIで1日分の固定値を書き込む
過去月指定時に確認してから書き込む
このように履歴が残ると、後から見返したときにも、何をどの段階で実装したのか分かりやすくなります。
まとめ
今回一番学びになったのは、実装力以前に「タスクを分解する力」が重要だということです。
大きな機能をそのまま見ると難しく感じます。
しかし、今回のように
- APIに接続する
- 固定値を書き込む
- 1日分を計算する
- 1ヶ月分に広げる
- 実運用で危ないところを見直す
のように分けると、一つずつ確認しながら進められます。
Javaを学習するきっかけとして、社内で読んだ記事の影響もありました。
山内さんの記事では、生成AIの時代において、単に文法を知ってコードを書くことよりも、要望や要求を抽象化し、破綻しない要件に落とし込み、適切な形で具体化していく力が重要になるという趣旨が書かれていました。
今回の実装を通して、自分もまさにその部分を体感することができました。
ただ、それ以上に大事だったのは、「何を作るのか」「どの順番で確認するのか」「どこに業務上のリスクがあるのか」を考えることでした。
今はAIがコードを書く部分をかなり助けてくれます。
だからこそ、いきなり実装に入るのではなく、まず作りたいものを小さな確認単位に分ける力が重要になっていると感じます。
実装中に手が止まると、つい技術力が足りないように感じます。
もちろん知識不足が原因のこともありますが、実際にはタスクの粒度が大きすぎるだけの場合もあると思います。
何から手をつければいいか分からないときは、いきなり完成形を目指すのではなく、まず次に確認できる最小単位まで分解してみる。
地味ですが、実装を前に進めるうえでかなり有効な考え方だと思います。


