文化祭や学祭、サークルの単発イベント向けに、シフトを自動で割り振るWebアプリ「シフっと」を作りました。
ログイン不要です。幹事がイベントを作ってURLを配り、参加者が出られる枠をタップするだけでシフト表ができます。
作ったきっかけ
自分が幹事をやったとき、シフト組みでかなり消耗しました。LINEグループで「いつ空いてる?」と聞いて、返ってきた返信を表に写して、役割ごとの人数を数えて、足りない枠を埋めるために個別に頼む。これを日程が変わるたびにやり直します。
既存のツールも見ましたが、日程調整ツールは「全員が集まれる日」を探すもので、「この枠に受付2人、調理3人」を埋める用途には合いませんでした。シフト管理サービスはアルバイト向けが多く、数日だけのイベントにアカウント登録を頼むのは気が引けます。
それなら自分で作るか、となったのが始まりです。
使い方
幹事
- 日程、1日の時間帯、1枠の長さ、役割と必要人数を入れてイベントを作る
- 発行された参加者用URLをLINEに貼る
- 回答が集まったら「自動割り当てを実行」を押す
- 赤く表示された不足枠を手で直して、確定・公開する
固定した人は、再実行しても動きません。手で入れるときは必要人数を超えて入れてもよく、超えた枠には +1 のような表示が付きます。当日の予備要員を置けるように、この仕様にしました。
時間枠は作成後にも追加や時刻の変更ができます。確定前のシフト表は公開できないようにしてあります。
参加者
URLを開いて名前を入れ、枠を ○ / △ / × でタップして送信するだけです。後から同じ名前で開けば修正できます。
スマホとLINE内ブラウザでも一通り操作を確認しました。特に問題は出ていません。
技術構成
| 用途 | 使ったもの |
|---|---|
| フレームワーク | Next.jsと TypeScript |
| ホスティング | Cloudflare Workers |
| DB | Cloudflare D1 |
| スタイル | Tailwind CSS |
開発の流れ
最初の版は1日でざっと作りました。Next.js の雛形を作り、昼過ぎに自動割り当てまで動く MVP、夕方にはデプロイしています。最初はデータをJSONファイルに保存していて、Workers ではそれが使えないので、デプロイ直後に D1 へ移しました。
その後、実際に触って気になったところを直していきました。大きかったのは次の2つです。
- 同じ名前で回答すると、前の回答を黙って上書きしていた
- 人数を増やすと、自動割り当てが遅くなった
以下、この2つと、割り当てソルバーの中身を書きます。
割り当てソルバー
方式
最初は整数計画法で解くつもりで、javascript-lp-solver を入れていました。ただ、制約の数が多く、導入とチューニングに時間がかかりそうだったので、やめました。いまは貪欲法で初期解を作り、山登り法で改善しています。
最小化するのは次の5項目です。
| 項目 | 重み |
|---|---|
| 人の不足 | 1000 |
| △の枠への割り当て | 50 |
| 担当数の偏り | 20 |
| 細切れのシフト | 20 |
| 役割の切り替え | 1 |
細切れの重みは、最初は5でした。1時間おきに入って抜けるようなシフトは、組む側としては人数が合っていても、入る側からすると最悪です。なので20まで上げました。
上限枠数、連続枠数、できない役割といったハード制約は、どの手順でも破りません。代わりに、最適解である保証はありません。
差分更新に書き換えた
最初の実装は、1手動かすたびに目的関数を全部計算し直していました。人数と枠が少ないうちはこれで十分でしたが、規模が大きくなると目に見えて遅くなります。そこで、次のように書き換えました。
- 参加者や枠は添字で持ち、回答(○△×)は
Int8Arrayに詰める - 枠ごとの充足数、人ごとの日別件数、担当枠数の分布は、割り当てるとき・外すときに更新する
- 局所探索で1手を評価するときは、入れ替える2人分の差分だけを計算する
// 人数が増えても重くならないよう、状態は添字ベースの配列で持ち、充足数・日別件数・
// 担当枠数の分布は assign/unassign のたびに更新する。局所探索の各手は当事者2人分の
// 差分だけで評価し、目的関数全体の再計算はしない。
探索の時間上限は9秒にしています。最初の1秒は乱数を変えて別の初期解からやり直し、いちばん良い案を採ります。30回続けて改善がなければ打ち切ります。大きな入力では1回目で時間を使い切るので、やり直しは実質起きません。
D1には1イベント1行で保存
イベントのデータは正規化せず、1行のJSON列に丸ごと入れています。SQLで集計はできませんが、読んで書き戻すだけなので実装はかなり楽です。想定は1イベント最大100人×200枠で、この規模なら困らないと見ています。
同時書き込みは、version列を使った楽観的ロックで守っています。読んだときのversionと一致する行だけを更新し、競合したら再試行します。
同じ名前の扱い
参加者はログインしないので、名前で区別しています。最初の版では、同じ名前で送ると前の回答を黙って上書きしていました。人の名前を選んだまま送信したら、その人の回答が消えます。
いまは「○○さんはすでに回答しています。本人ですか?」と確認を出し、OKしたときだけ上書きします。同姓同名の別人を見分ける仕組みではないので、押し間違いの事故を減らす程度の対策です。
幹事の認証はPINだけ
幹事用の秘密URLは発行しません。参加者用URLの下にある「幹事の方はこちら」から入ります。PINは任意で4桁以上、保存はsha256のハッシュだけです。
PINを設定しなければ、参加者URLを知っている人は誰でも管理画面に入れます。作成時に警告は出していますが、甘めの割り切りだと自分でも思っています。PINを忘れたときの救済もまだありません。
まだできていないこと
- 自動テストがない。ソルバーを書き直したので、まずハード制約のテストを書きたい
- 回答の締切機能
- 30日後の自動削除。期限は保存しているのに、消すジョブがない
- 50人が同時に回答したときの負荷試験
おわりに
文化祭の実行委員やサークルの幹事をやる人が周りにいたら、教えてもらえると嬉しいです。バグや使いにくいところの指摘は、コメントで気軽にどうぞ。