1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

文化祭のシフトを自動で割り振るWebアプリを作った。ソルバーは差分評価の山登り法で書き直した

1
Posted at

文化祭や学祭、サークルの単発イベント向けに、シフトを自動で割り振るWebアプリ「シフっと」を作りました。

ログイン不要です。幹事がイベントを作ってURLを配り、参加者が出られる枠をタップするだけでシフト表ができます。

作ったきっかけ

自分が幹事をやったとき、シフト組みでかなり消耗しました。LINEグループで「いつ空いてる?」と聞いて、返ってきた返信を表に写して、役割ごとの人数を数えて、足りない枠を埋めるために個別に頼む。これを日程が変わるたびにやり直します。

既存のツールも見ましたが、日程調整ツールは「全員が集まれる日」を探すもので、「この枠に受付2人、調理3人」を埋める用途には合いませんでした。シフト管理サービスはアルバイト向けが多く、数日だけのイベントにアカウント登録を頼むのは気が引けます。

それなら自分で作るか、となったのが始まりです。

使い方

幹事

  1. 日程、1日の時間帯、1枠の長さ、役割と必要人数を入れてイベントを作る
  2. 発行された参加者用URLをLINEに貼る
  3. 回答が集まったら「自動割り当てを実行」を押す
  4. 赤く表示された不足枠を手で直して、確定・公開する

固定した人は、再実行しても動きません。手で入れるときは必要人数を超えて入れてもよく、超えた枠には +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人が同時に回答したときの負荷試験

おわりに

文化祭の実行委員やサークルの幹事をやる人が周りにいたら、教えてもらえると嬉しいです。バグや使いにくいところの指摘は、コメントで気軽にどうぞ。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?