日程調整サービス「日程組」を作りました。
候補日を作ってURLを共有するだけで、参加者がログイン不要で回答できる日程調整・出欠管理ツールです。
作ったもの
主催者がイベント名と候補日を入力すると、共有URLが発行されます。
参加者はそのURLを開いて、各候補日に対して次の4つで回答できます。
○△✕-
それぞれの記号が何を指すかは、イベント作成者が自由に決められるようにしました。
たとえば「○: 絶対OK」「△: 他卓調整中」「✕: NG」「-: その他」みたいに、説明欄でそのイベントに合ったルールを書けます。
- を選んだ場合は、その日ごとのコメントも残せます。
たとえば「夕方以降ならOK」「仕事次第」「抽選結果による」みたいな、単純な △ だけでは伝わりにくい情報を残せるようにしました。
主な機能
- ログイン不要でイベント作成・回答
- 共有URLの発行
-
○ / △ / ✕ / -の4択回答 -
-選択時の日別コメント - 候補日の手動追加
- 範囲指定で候補日をまとめて追加
- カレンダーUIから候補日をまとめて選択
- 曜日ごとの一括選択・解除
- 候補日のドラッグ並び替え
- 候補日の自動日付順ソート
- 戻る / 進む
-
.ics / .zipファイル読み込み - Googleカレンダーのzipエクスポート対応
- zip内の誕生日カレンダーを自動除外
- 回答時に
.ics / .zipを読み込んで、予定がある日をまとめて✕にする - 日付範囲 + 時間帯で回答を一括変更
- スマホで長押ししながらなぞって回答をまとめて入力
- 回答一覧の縦表示 / 横表示切り替え
- ダークモード
- Vercel Analytics / Speed Insights
技術スタック
| 役割 | 技術 |
|---|---|
| フレームワーク | Next.js App Router |
| UI | React |
| 言語 | TypeScript |
| スタイリング | Tailwind CSS |
| データベース | Supabase |
| ホスティング | Vercel |
| カレンダー解析 | ical.js |
| zip展開 | fflate |
| 並び替え | dnd-kit |
| 計測 | Vercel Analytics / Speed Insights |
データ構造
データは大きく4つに分けています。
| テーブル | 内容 |
|---|---|
events |
イベント本体 |
candidates |
候補日 |
responses |
回答者 |
answers |
各候補日に対する回答 |
イメージとしてはこうです。
events
└ candidates
└ responses
└ answers
イベントごとに候補日があり、回答者ごとに各候補日への回答を保存しています。
回答値はTypeScript上でも ○ | △ | ✕ | - に限定しています。
export type AnswerValue = '○' | '△' | '✕' | '-'
文字列なら何でも入る状態にせず、アプリ内で扱う回答の種類を明確にしました。
工夫したところ
1. 候補日の入力をできるだけ楽にした
日程調整ツールで面倒なのは、候補日を作るところだと思っています。
1日ずつ入力するだけだと、候補日が多いイベントでかなりしんどいです。
そこで、次の入力方法を用意しました。
- 1日ずつ追加
- 日付範囲でまとめて追加
- カレンダーから複数日を選択
- 曜日を押して、その曜日を一括選択
- 「この月の今日以降」をまとめて選択
-
.ics / .zipから予定がある日を除外
カレンダーUIでは、日付を長押ししてなぞるように選択できます。
スマホでも候補日をまとめて選びやすくしたかったので、この操作感はかなり調整しました。
2. .ics / .zip をブラウザ内で処理する
Googleカレンダーから予定を書き出すと、.ics ではなくzipでダウンロードされることがあります。
そこで、.ics だけでなく .zip もそのまま読み込めるようにしました。
処理の流れはざっくりこうです。
ファイル選択
↓
zipなら fflate で展開
↓
.ics ファイルを抽出
↓
ical.js で予定を解析
↓
候補日と予定の時間が重なるか判定
↓
予定あり / 予定なし に応じて反映
ファイルはブラウザ内で処理し、サーバーには送信・保存しないようにしています。
Googleカレンダーのzipには誕生日カレンダーが含まれることがあるので、ファイル名から誕生日カレンダーを自動で除外する処理も入れました。
実装では、zipかどうかを判定して、zipの場合は中の .ics だけを取り出しています。
const { strFromU8, unzipSync } = await import('fflate')
const entries = unzipSync(new Uint8Array(await file.arrayBuffer()))
const icsEntries = Object.entries(entries)
.filter(([name]) => name.toLowerCase().endsWith('.ics'))
誕生日カレンダーはGoogleカレンダーのエクスポートzipに入ることがあるため、対象から外すようにしました。
3. 回答側でも .ics / .zip を使えるようにした
主催者だけでなく、回答者側でもカレンダーファイルを読み込めます。
予定が候補日と重なる場合、まとめて ✕ にできます。
さらに、予定ありの場合と予定なしの場合で、それぞれ入力する記号を選べるようにしました。
デフォルトでは、
- 予定あり:
✕ - 予定なし:
○
になっています。
「予定がある日は全部✕、空いている日は全部○」みたいな回答が一気に作れるので、手入力の負担をかなり減らせます。
4. スマホでなぞって回答できるようにした
候補日が多いと、1つずつボタンを押すのも面倒です。
そこで回答ボタンを長押しして、そのまま上下になぞると、複数行にまとめて同じ回答を入力できるようにしました。
さらに横にずらすと別の記号も入力できます。
たとえば ○ を長押しして下になぞれば、複数日をまとめて ○ にできます。
実装では、スマホのスクロール操作と競合しないようにしました。
- タップだけなら普通に1つだけ入力
- 長押しが成立するまではスクロールを邪魔しない
- 長押し後は回答入力として扱う
- 画面端まで指が来たら自動スクロール
このあたりはかなり試行錯誤しました。
5. 戻る / 進むを入れた
候補日を大量に追加したり、回答をまとめて変更したりすると、間違えたときのダメージが大きいです。
そこで、候補日編集にも回答入力にも「戻る / 進む」を入れました。
一括操作を気軽に使えるようにするには、取り消しできることがかなり大事だと感じました。
詰まったところ
スマホのレイアウト
候補日編集画面では、日付・開始時刻・終了時刻・削除ボタンを1行に収める必要がありました。
PCでは余裕がありますが、スマホだとすぐ折り返されます。
日付欄と時間欄の幅をかなり調整して、スマホでも1行で見えるようにしました。
タッチ操作とスクロールの両立
長押し入力を作ると、スマホのスクロールとぶつかります。
最初から preventDefault() してしまうと、普通にスクロールしたいときまで邪魔になります。
逆に何もしないと、長押し入力中にページがスクロールして操作しづらくなります。
そのため、
- 長押し前はスクロール優先
- 長押し後は入力優先
になるようにしました。
.ics の扱い
.ics は単純な日付リストではなく、終日予定、時間つき予定、繰り返し予定などがあります。
繰り返し予定は ical.js で展開し、候補日の範囲と重なるものだけを予定ありとして扱っています。
予定の判定では、キャンセル済みの予定は除外しています。
function isBlockingCalendarEvent(vevent: CalendarComponent): boolean {
const status = String(vevent.getFirstPropertyValue('status') ?? '').toUpperCase()
return status !== 'CANCELLED'
}
ログイン不要にした理由
日程調整は、参加者にとって「できるだけ早く回答したい」ものだと思っています。
ログインが必要になると、その時点で回答のハードルが上がります。
なので今回は、URLを知っていれば回答できる設計にしました。
一方で、ログイン不要にすると編集権限や削除権限の扱いは難しくなります。
現在は使いやすさを優先していますが、今後は編集用トークンを分けるなど、もう少し安全な権限管理も検討したいです。
今後やりたいこと
- 編集権限まわりの強化
-
.ics / .zipのファイルサイズ制限 - Googleカレンダー連携
- OGP画像の改善
- 回答一覧の見やすさ改善
- 大量候補日・大量回答時のパフォーマンス改善
まとめ
日程調整ツール「日程組」を作りました。
特にこだわったのは、候補日入力と回答入力の手間を減らすことです。
範囲指定、カレンダー選択、.ics / .zip 読み込み、長押し入力、戻る / 進むなどを入れて、できるだけ少ない操作で日程調整できるようにしました。
まだ改善したいところはありますが、実際に使える形まで作れたので、ぜひ触ってみてもらえると嬉しいです。