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?

文化祭シフトアプリ「シフっと」のその後。履歴の差分表示と、役割の編集を足した

1
Posted at

文化祭やサークルのシフトを自動で割り振るWebアプリ「シフっと」の、前回の記事からの更新分を書きます。

アプリ全体の話は1本目、割り当てソルバーの話は2本目に書きました。今回はその後の変更のうち、書き残しておきたいものを3つに絞ります。

今回やったこと

変更 概要
回答履歴に変更内容を表示 「何が変わったか」を○△×の差分で見られる
役割の追加・名前変更・削除 イベント作成後に役割を編集できる
シフト表からの共有ボタン LINEとXで、アプリを人に紹介できる

ほかに、開発者ページ、お問い合わせ、OGPなど、サイトとしての体裁も整えました。ここでは触れません。

回答履歴に、変更内容を表示する

幹事は、参加者の回答を代わりに編集できます。編集するたびに履歴が残り、「この時点に戻す」で復元できます。

ただ、履歴に出ていたのは「回答を編集」のような要約だけでした。復元していいのかを決めるには、何が変わったのかを見たいところです。「この時点に戻す」を押すのが怖い、という状態でした。

履歴には変更前しか入っていない

履歴は、変更前のスナップショットだけを持っています。

type AvailabilitySnapshot = {
  displayName: string;
  maxSlots: number | null;
  disabledRoleIds: string[];
  answers: { slotId: string; value: AvailabilityValue }[];
};

変更後の状態は持っていないので、差分を出すには「変更後」を別の場所から持ってくる必要があります。履歴は新しい順に並んでいるので、次のように考えました。

  • いちばん新しい履歴の「変更後」は、現在の状態
  • それより古い履歴の「変更後」は、1つ新しい履歴の「変更前」

つまり、現在の状態から出発して、新しい方から順にたどればよいことになります。

let after = 現在の状態;
for (const h of participantHistory) {   // 新しい順
  if (!h.snapshotBefore) continue;
  out.set(h.id, diffSnapshots(h.snapshotBefore, after, ...));
  after = h.snapshotBefore;             // 1つ古い履歴の「変更後」になる
}

履歴の型を変えて「変更後」も持たせる手もありました。ただ、すでに保存されている履歴が読めなくなるので、やめています。

差分の出し方

名前、最大枠数、できない役割、枠ごとの回答を比べます。表示はこうなります。

・最大枠数: 未設定 → 4
・できない役割に追加: 調理
・10/12 10:00〜11:00: ○ → ×

枠の回答は、いま存在する枠だけを比べています。履歴に残っていても、すでに削除された枠は表示のしようがないからです。

重くしない

差分は、履歴を開いている人の分だけ計算します。全員分を毎回計算すると、参加者が多いイベントで重くなるからです。

変更が4件以上あるときは「詳細(N件)」に折りたたみ、3件までは最初から展開します。これは、履歴を何件も並べたときに、画面が差分だらけになったので入れました。

役割を、あとから編集できるようにする

これまで、役割はイベント作成時に決めたきりで、あとから変えられませんでした。「受付」を「受付・案内」にしたい、あとから「呼び込み」を足したい、という声が、使ってみた人から出ていました。

削除は連鎖する

追加と名前変更は単純です。難しいのは削除でした。役割は、次のような場所から参照されています。

  • 枠ごとの必要人数
  • 割り当て済みのシフト
  • 参加者ごとの「できない役割」

これらを全部そろえて消さないと、存在しない役割を指すデータが残ります。

bundle.roles = bundle.roles.filter((r) => r.id !== roleId);
bundle.roles.forEach((r, i) => (r.sortOrder = i));
bundle.requirements = bundle.requirements.filter((q) => q.roleId !== roleId);
bundle.assignments = bundle.assignments.filter((a) => a.roleId !== roleId);
for (const p of bundle.participants) {
  p.disabledRoleIds = p.disabledRoleIds.filter((id) => id !== roleId);
}

イベントを1行のJSONで持っているので(1本目に書いた構成です)、これは1回の書き込みで済みます。正規化していたら、トランザクションを考える場面でした。

古い画面からの送信を弾く

役割を消せるようになると、別の問題が出ます。幹事が役割を削除したあとに、古い画面を開いたままの人が送信してくる、ということが起こります。

  • 参加者の回答に、削除済みの役割が「できない」として入っている
  • 幹事の別タブから、削除済みの役割の必要人数や手動割り当てが送られてくる

対策は、サーバー側で存在確認をすることです。

  • 回答の disabledRoleIds は、存在する役割だけに絞って保存する(エラーにはしない)
  • 必要人数の更新と手動割り当ては、存在しない枠・役割ならエラーにする

前者を黙って絞るのは、参加者に原因が分からないエラーを見せたくなかったからです。履歴の復元でも同じ値が入りうるので、同じ処理を通しています。

制限

  • 確定済みのイベントでは、役割を変更できません(確定を取り消せば変更できます)
  • 同じ名前の役割は作れません
  • 最後の1つは削除できません
  • 追加した役割の必要人数は、全作業枠で同じ値になります。枠ごとに変えたいときは、あとから必要人数の画面で直します

シフト表の下に、共有ボタンを置く

参加者がシフト表を見終わったところに、「LINEで送る」「Xで投稿」のボタンを足しました。参加者の多くは、アプリを知らないまま幹事に言われたURLを開いた人です。そこで「便利だった」と思ってもらえれば、次の幹事になるかもしれません。

気をつけたのは、共有するURLです。

// 共有するのはサイトのURL。シフト表のURLは参加者限定なので載せない
const shareText = encodeURIComponent(`${文言} ${SITE_URL}${localePath(lang, "/")}`);

現在のページのURLをそのまま共有すると、イベントのシフト表が外に出てしまいます。このアプリは、URLを知っている人だけが見られる作りなので、共有するのはトップページだけにしています。

まだできていないこと

  • 自動テストがまだありません。2本目の記事にも書きましたが、前回から増えていません。役割の削除のような「連鎖して消す」処理こそ、先に書くべきでした
  • 役割の削除で割り当てが消えるとき、確認ダイアログの文言が十分か、自信がありません
  • 履歴の差分は、削除された枠の分を出せません

触ってもらえると助かります

ログイン不要で、無料です。

「ここが分かりにくい」「こういう機能がほしい」といった指摘は、コメントでお待ちしています。

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?