React×Google Calendarでオリジナルタスク管理アプリを作った話
Google Calendarのデータを使って、自分好みのタスク管理アプリを作りたい——そう思ったのは、既存のタスクアプリに限界を感じたからだ。
TodoistもNotionも試した。でも「カレンダーと完全同期しながら、独自のビューで作業を管理する」という要件を満たすツールがなかった。だったら作ればいい。
なぜGoogle Calendar APIを選んだか
タスク管理アプリを自作するとき、データの保存先は大きな選択肢だ。独自DBを持つか、既存サービスのAPIを使うか。
Google Calendar APIを選んだ理由は3つある。
- すでにデータがある — 仕事の予定はずっとGoogleカレンダーに入れていたので、タスクと予定が同一データソースに乗る
- 無料枠が十分 — APIの無料クォータは個人利用には余裕がある
- モバイルアプリとの同期が自動 — iPhoneのカレンダーアプリと自動同期されるので、外出先でも確認できる
技術スタック
フロントエンド: React 18 + Vite
スタイリング: Tailwind CSS
API連携: Google Calendar API v3
認証: OAuth 2.0(Google Identity Services)
状態管理: useState + useContext(小規模なので)
ホスティング: Vercel
Google Calendar APIのセットアップ
OAuth 2.0の設定
Google Cloud Consoleでプロジェクトを作り、Calendar APIを有効化する。OAuthクライアントIDを発行する際、承認済みのJavaScriptオリジンにローカル開発URL(http://localhost:5173)と本番URL(https://your-app.vercel.app)を追加する。
// src/lib/googleAuth.js
const CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const SCOPES = 'https://www.googleapis.com/auth/calendar';
export function initGoogleAuth(onSuccess, onError) {
google.accounts.oauth2.initTokenClient({
client_id: CLIENT_ID,
scope: SCOPES,
callback: (response) => {
if (response.error) {
onError(response.error);
return;
}
localStorage.setItem('gapi_token', response.access_token);
onSuccess(response.access_token);
},
});
}
カレンダーイベントの取得
// src/lib/calendarApi.js
const BASE_URL = 'https://www.googleapis.com/calendar/v3';
export async function fetchEvents(calendarId = 'primary', options = {}) {
const token = localStorage.getItem('gapi_token');
const { timeMin, timeMax, maxResults = 100 } = options;
const params = new URLSearchParams({
maxResults,
orderBy: 'startTime',
singleEvents: true,
...(timeMin && { timeMin }),
...(timeMax && { timeMax }),
});
const res = await fetch(
`${BASE_URL}/calendars/${encodeURIComponent(calendarId)}/events?${params}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!res.ok) {
throw new Error(`Calendar API error: ${res.status}`);
}
const data = await res.json();
return data.items;
}
Reactコンポーネントの設計
タスク一覧とカレンダーの同期
アプリの核心は「カレンダーイベント」と「タスク」を同一データとして扱う部分だ。
Google Calendarのイベントに独自プロパティ(extendedProperties)を持たせることで、タスクのステータス(完了/未完了)や優先度を保存できる。
// src/hooks/useTasks.js
import { useState, useEffect, useCallback } from 'react';
import { fetchEvents, updateEvent, createEvent } from '../lib/calendarApi';
export function useTasks(calendarId) {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(false);
const loadTasks = useCallback(async () => {
setLoading(true);
try {
const now = new Date();
const events = await fetchEvents(calendarId, {
timeMin: new Date(now.getFullYear(), now.getMonth(), 1).toISOString(),
timeMax: new Date(now.getFullYear(), now.getMonth() + 2, 0).toISOString(),
});
// extendedProperties.private.task_type === 'task' のもののみ抽出
const taskEvents = events.filter(
(e) => e.extendedProperties?.private?.task_type === 'task'
);
setTasks(taskEvents);
} finally {
setLoading(false);
}
}, [calendarId]);
useEffect(() => {
loadTasks();
}, [loadTasks]);
const completeTask = async (taskId) => {
const task = tasks.find((t) => t.id === taskId);
if (!task) return;
const updated = await updateEvent(calendarId, taskId, {
extendedProperties: {
private: {
...task.extendedProperties?.private,
status: 'done',
},
},
});
setTasks((prev) => prev.map((t) => (t.id === taskId ? updated : t)));
};
return { tasks, loading, reload: loadTasks, completeTask };
}
タスクカードコンポーネント
// src/components/TaskCard.jsx
export function TaskCard({ task, onComplete }) {
const isDone = task.extendedProperties?.private?.status === 'done';
const priority = task.extendedProperties?.private?.priority || 'medium';
const priorityColor = {
high: 'border-red-400',
medium: 'border-yellow-400',
low: 'border-green-400',
}[priority];
return (
<div
className={`border-l-4 ${priorityColor} bg-white rounded-r-lg p-4 shadow-sm ${
isDone ? 'opacity-50' : ''
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<h3 className={`font-medium ${isDone ? 'line-through text-gray-400' : ''}`}>
{task.summary}
</h3>
{task.description && (
<p className="text-sm text-gray-500 mt-1">{task.description}</p>
)}
<p className="text-xs text-gray-400 mt-2">
{new Date(task.start.dateTime || task.start.date).toLocaleDateString('ja-JP')}
</p>
</div>
{!isDone && (
<button
onClick={() => onComplete(task.id)}
className="text-sm bg-blue-500 text-white px-3 py-1 rounded hover:bg-blue-600"
>
完了
</button>
)}
</div>
</div>
);
}
実際に使ってみてわかったこと
よかった点
カレンダービューとタスクビューの切り替えが便利。同じデータをカレンダー表示でもリスト表示でも見られる。「今週やること」を週次ビューで一覧するのが特に気持ちいい。
iPhoneのカレンダーアプリに自動で出る。わざわざアプリを開かなくても、通知で今日のタスクを確認できる。
苦労した点
OAuth 2.0のトークン管理が一番ハマった。access_tokenの有効期限が1時間で、期限切れ後の自動リフレッシュ実装が思ったより複雑だった。最終的にlocalStorageにトークンを保存して、APIエラー時に再認証フローを走らせる方式で落ち着いた。
extendedPropertiesの容量制限も注意点だ。プライベートプロパティのサイズには上限があるので、大量のデータを詰め込もうとするとエラーになる。タスクの詳細テキストはdescriptionフィールドに入れるのが正解。
ハマりポイント集
CORS問題: Google Calendar APIはブラウザから直接叩けるが、承認済みオリジンの設定ミスで詰まりやすい。Cloud Consoleの設定を確認すること。
singleEvents: trueを忘れる: 繰り返しイベントはsingleEvents: trueをつけないと正しく展開されない。
タイムゾーン: timeMin/timeMaxのパラメータはISO 8601形式(Zサフィックス付き)でないと日本時間とズレる。
まとめ
React + Google Calendar APIの組み合わせは、個人開発のタスク管理アプリとして十分実用的だ。
特に「すでにGoogleカレンダーを使っている人」にとっては、データを一元管理できる点が大きなメリットになる。OAuthやAPIの初期設定は多少面倒だが、一度動き始めれば安定している。
カスタムビューや独自フィルタリングを追加しやすいReactのコンポーネント設計と組み合わせれば、市販ツールでは実現できない自分専用の管理体験を作れる。