Microsoft Graph API を使って予定表を読み取っていると、「Outlook には見えているのに、特定ユーザーだけ一部の予定が Graph から返ってこない」という相談は意外と多くあります。とくに Europe/London(GMT Standard Time)のように夏時間が絡むテナントでは、UTC 変換やクエリ期間の指定を少し間違えるだけで簡単に「予定の取りこぼし」が発生します。本記事では、.NET(C#)+アプリケーション権限で Graph を利用しているケースを前提に、実務でよくハマる原因とその対処方法、再発防止のベストプラクティスを詳しく解説します。
シナリオのおさらい:自分には返るのに同僚には返らない予定
まずは相談内容の典型パターンを整理します。
- .NET Core 3.1 の Web アプリ(バックグラウンドジョブ)で Microsoft Graph SDK を利用
- アプリケーション権限(client credentials flow)で
Calendars.Read.Allなどを利用 - 週単位のカレンダービューを取得するために、以下のエンドポイントを使用
GET /users/{id}/calendar/calendarViewGET /users/{id}/events
- 同じ定期的な Teams 会議に、自分と同僚 A が参加している
- Outlook(旧 UI / 新 UI とも)では双方の予定表に同じ会議が表示されている
- しかし Graph からの取得では「自分の
calendarViewには返ってくるのに、同僚 A のcalendarViewにはその会議が含まれない」 - クエリは「月曜開始〜日曜終了」の 1 週間、レスポンスは
Prefer: outlook.timezone="Europe/London"
一見すると「Graph API 側のバグ?」と思いたくなりますが、多くの場合はアプリ側のクエリやタイムゾーン処理に原因があります。以下では、原因切り分けと再発防止の観点から、優先して確認すべきポイントを順番に見ていきます。
予定が一部返ってこないときに最初に疑うべきポイント
現場でのトラブルシュートの経験上、次の 3 つを上から順に潰していくと、かなりの確率で問題を解消できます。
- UTC 変換ミス(とくに Europe/London の夏時間境界)
calendarViewのページング未実装- 対象カレンダーの取り違え(主カレンダー vs 別カレンダー)
さらに、状況によっては以下も絡んできます。
- 予定の「同一性」の認識違い(参加者ごとにコピーが存在する)
- アプリケーション権限・テナントポリシー・プライバシー設定
- DST(夏時間)切り替え週の境界テスト不足
ここで全体像を把握しやすいように、代表的な原因と症状・対策を表にまとめておきます。
| 原因 | よくある症状 | 主な対策 |
|---|---|---|
| UTC 変換ミス | 週の端(深夜・早朝)の予定だけ抜ける / 特定ユーザーだけ抜ける | ローカル時間を正しく UTC に変換し、startDateTime/endDateTime に渡す |
| ページング未対応 | イベントが多いユーザーで後半の予定がごっそり抜ける | @odata.nextLink(SDK の NextPageRequest)を最後まで辿る |
| カレンダー ID の取り違え | 特定ユーザーだけ予定が返らない/別カレンダーにだけ存在 | /calendars で一覧取得し、正しいカレンダー ID を指定 |
| iCalUId の誤解 | 同じ会議のはずなのに数が合わない | iCalUId をキーに /events で突合する |
| 権限・プライバシー | プライベート予定だけ見えない / テナントによって挙動が違う | スコープ・管理者同意・isPrivate の挙動を確認 |
| DST 境界 | 夏時間切替週だけ予定が抜ける | 切替週を含むテストケースを用意して UTC 変換を再検証 |
UTC 変換の誤りを正す:最頻出の落とし穴
最も多いのが、クエリに渡す startDateTime / endDateTime を正しく UTC に変換しないまま Z を付けて送ってしまうパターンです。
Microsoft Graph の /calendarView エンドポイントでは、クエリパラメーターの startDateTime / endDateTime は UTC 前提です。たとえば「ロンドン時間の 2023/10/02 00:00:00」から 1 週間の予定を取りたい場合、次のような処理が必要です。
// ロンドン時間(Windows の ID は "GMT Standard Time")
var tz = TimeZoneInfo.FindSystemTimeZoneById("GMT Standard Time");
DateTime startOfWeekLocal = /* 月曜 00:00:00 (Europe/London) */;
DateTime endOfWeekLocal = /* 翌週月曜 00:00:00 (Europe/London) */;
// ローカル時間 → UTC へ変換
var startUtc = TimeZoneInfo.ConvertTimeToUtc(startOfWeekLocal, tz);
var endUtc = TimeZoneInfo.ConvertTimeToUtc(endOfWeekLocal, tz);
var queryOptions = new List<QueryOption>
{
new QueryOption("startDateTime", startUtc.ToString("o")),
new QueryOption("endDateTime", endUtc.ToString("o"))
};
var page = await graphClient.Users[userId].Calendar.CalendarView
.Request(queryOptions)
.Header("Prefer", "outlook.timezone=\"Europe/London\"")
.Top(50) // ページングは後述
.GetAsync();
ありがちな誤りは、次のようなコードです。
// ❌ ロンドン時間の DateTime に対して、そのまま "Z"(UTC)付きで文字列化してしまう例
var start = startOfWeekLocal.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
これでは「ロンドン時間を UTC に変換していないのに UTC として宣言している」ことになり、夏時間の期間(BST)では 1 時間分ずれてしまいます。その結果、週の端にある会議(例:日曜深夜・月曜早朝の定期会議など)がクエリ範囲から外れ、「自分のアカウントではたまたま週の中央にある予定しか見ていないから問題が顕在化していないが、同僚 A の予定構成では欠落する」といった現象が起こります。
Europe/London と UTC の具体的なずれ
Europe/London(GMT Standard Time)は、冬時間(GMT)と夏時間(BST)で UTC とのオフセットが変わります。
| 時期 | ロンドン時間 | 正しい UTC | 誤ったクエリ例(Z 付けるだけ) |
|---|---|---|---|
| 冬時間(GMT) | 2023-01-02 00:00 | 2023-01-02 00:00Z | 同じ(たまたま問題が見えない) |
| 夏時間(BST) | 2023-07-03 00:00 | 2023-07-02 23:00Z | 2023-07-03 00:00Z(1 時間ずれている) |
夏時間期間中は、クエリ範囲が実際より 1 時間右にずれるため、「本来 Sunday 23:30(BST)の予定」が範囲外に出てしまう、といった形で予定が抜け落ちます。Graph 側のバグではなく、クエリ側の時間解釈のズレがすべての元凶です。
安全なクエリ期間の考え方
さらに、終了時刻の扱いにも注意が必要です。よく「日曜 23:59:59 まで」と指定したくなりますが、API によっては終了時刻を排他的終端として扱う実装もあるため、「終了=翌週月曜 00:00:00」という区切りにしておく方が安全です。
- 開始:ロンドン時間の「月曜 00:00:00」
- 終了:ロンドン時間の「翌週月曜 00:00:00」
- 両方を正しく UTC 変換してクエリに渡す
このパターンであれば、週の終端付近にある予定を取りこぼすリスクを最小化できます。
calendarView のページングを実装する:イベントが多いユーザーほど危険
2 つ目によくあるのが、ページングを考慮していないために、1 ページ目以降の予定がすべて欠落しているケースです。
/calendarView は内部的にページングされており、1 回のレスポンスで返されるイベント数には上限があります。SDK を使う場合でも、明示的に .Top(n) を指定した場合でも、一定件数を超えると @odata.nextLink に次ページへのリンクが返却されます。これを辿らないと「イベントが多いユーザーほど後半の予定がごっそり抜ける」状態になります。
C#(Graph SDK)でのページング実装例
var events = new List<Event>();
var tz = TimeZoneInfo.FindSystemTimeZoneById("GMT Standard Time");
var startUtc = TimeZoneInfo.ConvertTimeToUtc(startOfWeekLocal, tz);
var endUtc = TimeZoneInfo.ConvertTimeToUtc(endOfWeekLocal, tz);
var queryOptions = new List<QueryOption>
{
new QueryOption("startDateTime", startUtc.ToString("o")),
new QueryOption("endDateTime", endUtc.ToString("o"))
};
// 1 ページ目を取得
var page = await graphClient.Users[userId].Calendar.CalendarView
.Request(queryOptions)
.Header("Prefer", "outlook.timezone=\"Europe/London\"")
.Top(50) // 1 ページあたり 50 件など
.GetAsync();
events.AddRange(page.CurrentPage);
// NextPageRequest が null になるまでループ
while (page.NextPageRequest != null)
{
page = await page.NextPageRequest.GetAsync();
events.AddRange(page.CurrentPage);
}
// events に 1 週間分の予定がすべて格納される
とくに、管理職や会議が多いユーザーは 1 週間の予定だけでも 100 件以上になることがあります。自分のアカウントでは 1 ページに収まっているので問題が見えず、予定が多い同僚 A にだけ欠落が現れる……というパターンはかなり典型的です。
主カレンダー限定呼び出しに注意:Calendar と Calendars
3 つ目のポイントは、どのカレンダーを対象にしているかです。
/users/{id}/calendar/...:主カレンダー(Calendar フォルダー)のみ/users/{id}/calendars:ユーザーが持つすべてのカレンダー一覧/users/{id}/calendars/{calendarId}/...:指定したカレンダーに対する操作
もし同僚 A が、主カレンダーではなく「別カレンダー」に Teams 会議を移動したり、共有カレンダーにだけ参加しているような場合、/calendar/calendarView だけを叩いてもその予定は返ってきません。
| エンドポイント | 対象 | よくある用途 |
|---|---|---|
/users/{id}/calendar/calendarView | 主カレンダーのみ | シンプルな「個人カレンダー」の週次取得 |
/users/{id}/calendars | すべてのカレンダー一覧 | どのカレンダーに予定があるかの調査 |
/users/{id}/calendars/{calendarId}/calendarView | 特定のカレンダー | チームカレンダー・別フォルダーの予定取得 |
トラブルシュート時には、次のような手順で確認するのが確実です。
/users/{id}/calendarsでカレンダー一覧を取得- 問題の会議がどのカレンダーに存在するかを
/eventsで検索 - そのカレンダー ID を使って
/calendars/{calendarId}/calendarViewを叩いてみる
これで「同僚 A だけ予定が返ってこない」原因が、単に「別カレンダーにあっただけ」なのか、それとも別の要因にあるのかを切り分けることができます。
iCalUId を使って参加者間の予定を突合する
同じ会議に複数の参加者がいる場合でも、各参加者のメールボックスには「別個のコピー」が存在します。そのため、自分のアカウントで見えている予定と、同僚 A のアカウントで見えている予定を機械的に突合したい場合は、iCalUId をキーとして利用するのが定石です。
iCalUId を用いた確認手順
- まず、自分の予定表から問題の会議を取得し、その
iCalUIdを控える - 同僚 A の
/users/{id}/eventsに対して次のようにクエリを投げる
GET /users/{otherUserId}/events?$filter=iCalUId eq '{その iCalUId}'
これにより、同僚 A のメールボックスに「その会議のコピー」が存在するかどうかを確認できます。結果が返ってこない場合、次の可能性が高くなります。
- 同僚 A が招待を承諾していない(
Tentative/Noneのまま) - 同僚 A 側で個別に削除・例外化している(キャンセルや例外インスタンス)
- 別カレンダーに保存されている(前述の主カレンダー問題)
また、繰り返し予定の場合は /events の結果に含まれるのはマスターアイテムであり、実際のインスタンスは /events/{id}/instances で展開されます。calendarView はこの展開を含めて「指定期間のビュー」を返している点も押さえておきましょう。
権限とプライバシー設定を点検する
アプリケーション権限を使っている場合でも、スコープやテナントのポリシー設定によって見える情報が変わることがあります。
代表的なスコープと違い
| スコープ | 概要 | 見える情報 |
|---|---|---|
Calendars.Read.All | すべてのユーザーのカレンダーを読み取り(アプリケーション) | 予定の本文や詳細を含めて取得可能 |
Calendars.ReadBasic.All | 件名や場所など基本情報だけを読み取り | 詳細はマスクされるが「枠」は見える |
Calendars.Read | サインイン中ユーザーのカレンダー読み取り(委任) | ユーザー自身が持つ権限に依存 |
通常、「予定が一部返ってこない」現象は Calendars.Read.All / ReadBasic.All の差だけでは起きづらく、「枠自体が見えない」場合は以下を疑います。
- アプリケーションに対する管理者同意が完了していない
- 条件付きアクセスやカスタムポリシーにより、特定ユーザーの予定表アクセスが制限されている
- 該当予定が
isPrivateになっており、テナントの設定でマスクされている
とくに本番テナントでは、検証用ユーザーと本番ユーザーで適用ポリシーが違うことがよくあります。「テストではうまくいったのに、特定の部署のユーザーだけ予定が返ってこない」という場合は、認証トークンに含まれるスコープと、テナント側のアクセス制御ルールをあらためて確認しましょう。
DST(夏時間)とタイムゾーンの境界を意識したテスト
Europe/London のように夏時間があるタイムゾーンでは、DST 切替週だけ予定が欠落するという非常に厄介なバグが出がちです。たとえば英国の場合、3 月末と 10 月末に 1 時間のズレが発生します。
テストケースに必ず含めたいポイント
- DST 切り替え「前週」「当週」「翌週」の 3 パターン
- 週の開始・終了付近に定期的な会議を配置(例:日曜 23:30、月曜 00:30)
- 異なるユーザーの予定構成で再現性を確認(会議の位置や数を変える)
これらのテストを通して、「ローカル時間の扱い」「UTC 変換」「終了時刻の取り方(翌週月曜 00:00:00)」が正しく実装されているかを検証しておくことで、本番環境での突発的なトラブルをかなり防げます。
Graph Explorer / cURL を使った切り分けの実践手順
コード側の問題か、Graph 側の問題かを切り分けるには、Graph Explorer や cURL を使って API を「素」で叩いてみるのが効果的です。
- 問題のユーザー(同僚 A)で Graph Explorer にサインインする
- 該当期間の
/me/calendarView?startDateTime=...&endDateTime=...を UTC の ISO8601 で直接叩く Prefer: outlook.timezone="Europe/London"をヘッダーに設定し、Outlook と同じ表示になるか確認$orderby=start/dateTimeを付けて結果を時間順に並べ、欠落している時間帯がないかを確認- 必要に応じて
/eventsと/events/{id}/instancesも参照し、繰り返し予定の扱いを確認
ここで Graph Explorer では正しく予定が返ってくるのに、アプリからの呼び出しでは欠落する場合、ほぼ確実にアプリ側のクエリ生成や認証周りに問題があると考えられます。
実装ベストプラクティスまとめ(再発防止チェックリスト)
最後に、同じ問題を繰り返さないためのベストプラクティスをチェックリスト形式でまとめます。新規実装・既存コードのレビュー時に活用してください。
クエリ期間・タイムゾーン
- クエリ期間は必ず「ローカル時間(Europe/London)」から開始・終了を決める
- 開始:月曜 00:00:00、終了:翌週月曜 00:00:00(ともにローカル時間)
TimeZoneInfoを用いてローカル時間 → UTC へ正しく変換するstartDateTime/endDateTimeには変換済みの UTC のみを渡し、Prefer: outlook.timezoneは「表示用」と割り切る
ページング
.Top(n)を指定する場合は必ずNextPageRequestを最後までたどる- イベントが多いユーザーをテスト用アカウントとして用意し、ページング処理の正しさを検証する
カレンダーの扱い
/calendarは主カレンダー限定であることを理解する- 必要に応じて
/calendarsで一覧を取得し、目的の予定がどのカレンダーに存在するかを特定する - 共有カレンダーやチームカレンダーを扱う場合は、明示的に
calendars/{calendarId}を指定する
予定の突合・一貫性
- 参加者間で同じ会議かどうかは
iCalUIdで判断する - 繰り返し予定は、
calendarViewと/events+/instancesで結果がどう違うかを理解しておく
権限・ポリシー
- アプリケーション権限は原則として
Calendars.Read.Allを利用し、管理者同意を確実に取る - テナントの条件付きアクセス・プライバシー設定を把握し、検証用テナントと本番テナントで設定差分がないかを確認する
テストと運用
- DST 切替週を含むテストケースを自動テストに組み込む
- 不具合が発生した場合は、まず Graph Explorer や cURL で「素の API」がどう応答しているかを確認する
- ログには実際に投げた
startDateTime/endDateTime(UTC)と、ユーザーのタイムゾーンを必ず記録する
よくある疑問への補足
Outlook と Graph API で見える予定の違いはなぜ起こる?
Outlook クライアントは、サーバー側の情報に加えてクライアント側のキャッシュやローカルの設定も加味して予定を表示します。Graph API は Exchange Online 上のサーバー状態をそのまま返すため、
- Outlook 側でキャッシュが残っているだけの予定
- クライアントでのみ存在する未同期のドラフト的な予定
などは Graph からは見えないことがあります。基本的には、最終的な「正」となるのはサーバー(Graph)の状態であり、Outlook の表示はそれにローカル要素が加わったもの、と理解しておくとよいでしょう。
calendarView と events の違いは?どちらを使うべき?
/calendarView:指定期間に存在する予定を「ビュー」として返す(繰り返し予定はインスタンス展開される)/events:カレンダーにあるイベント「アイテム」を返す(繰り返し予定はマスターが 1 件)
週単位・日単位の画面やレポートを作る場合は、通常は calendarView を使うのが適切です。一方で、「繰り返し予定の定義そのものを編集したい」「会議テンプレートとして扱いたい」などの要件がある場合は /events や /events/{id}/instances を併用することになります。
.NET 3.1 で実装しているが、今から作り直すなら?
.NET 3.1 はサポート終了しているため、これから新規に開発するのであれば .NET 6 以降への移行を検討するのがおすすめです。Graph SDK のバージョンも新しく保つことで、将来の API 変更への追従がしやすくなります。ただし、本稿で解説した「UTC 変換・ページング・カレンダー ID」といった考え方自体はフレームワークのバージョンに依存しないため、既存実装にもそのまま適用できます。
まとめ:まずは UTC・ページング・カレンダーの 3 点から
Microsoft Graph API で「特定ユーザーの calendarView だけ予定が返ってこない」という現象の多くは、
- UTC 変換のミス(とくに Europe/London の夏時間境界)
calendarViewのページング未対応- 主カレンダーと別カレンダーの取り違え
のいずれか、あるいはいくつかが組み合わさって発生しています。まずはこの 3 点をしっかりと押さえたうえで、iCalUId による突合、権限・ポリシーの確認、DST を意識したテストケースの整備を進めていくと、Outlook と Graph の見え方の差異に悩まされることは大幅に減るはずです。
本記事の内容を参考に、Graph API を使った予定表連携を「なんとなく動いている」状態から「再現性/信頼性の高い実装」へとブラッシュアップしてみてください。

コメント