tasteck
ブログ一覧に戻る
使い方

Build-in-Public Day 21: 来店型 Phase 2.3 を 3 周回した話 — sticky overlay + 受注画面 cascade フィルタ + 質問が UX を決める

シフト配置 UI を案 X → Z → D-1 と 3 周回した設計判断の記録。 ユーザーの『両方のいいところ取りたい』 から『スクロール往復が辛い』 への深掘りで sticky overlay + HTML5 DnD に着地。 受注画面では店舗 → クラス → キャスト → ルームの cascade フィルタ + cleaning_end_time 自動算出 + 『1 週間先予約は?』 のオペレーション質問でロジック分岐を確定。

5/14 (Day 21)、 来店型業態 (visit) の Phase 2.3 = シフト配置 UI と受注画面の isVisit 分岐を 1 日で進めた記録です。

UI 設計の 3 周回 (案 X → Z → D-1) とそこから学んだ「ユーザーの不満を 1 段階深く掘る」 ことの威力、 そして 「店舗 → クラス → キャスト → ルーム」 cascade フィルタ の運用順反映 + 「1 週間先予約はどうしてる?」 のオペレーション質問 で仕様判断を確定した過程を共有します。

1. シフト配置 UI: 3 周回した設計判断

来店型では「キャスト × ルーム × 時間」 のタイムライン管理が必要で、 各キャストが朝の DnD で部屋にアサインされる運用です。

最初に作った案は、 既存のキャストスケジュール画面に**「未アサイン」 行 + 部屋行を縦に並べる単純な構造**。 でもキャスト 10 人を未アサインに置くと縦長すぎて操作しにくい、 というフィードバックから設計議論が始まりました。

案 X: 上段 sticky 未アサインタイムライン + 下段 部屋タイムライン

最初に提案したのは 2 タイムライン分離。 上に未アサイン専用 (固定高さ + 縦スクロール)、 下に部屋タイムライン。 時間軸は両者で共有。

実装してデモ → ユーザーから 2 つ指摘:

  • 時刻ヘッダが上下に 2 つ出て冗長
  • DnD は同 Timeline 内のみで動く制約があり、 未アサイン → 部屋への移動がクリック経由になる = 手間

案 Z: 単一タイムライン + 未アサイン行を可変高さに

ユーザー判断「Z で行ってみよう」 で、 Timeline を 1 つに統合。 未アサイン行は groups 先頭に置き、 高さを max(70, maxOverlap * 32 + 10)同時刻被り最大人数に応じて自動拡張縮小

これで DnD は r-c-t ネイティブで動き、 時刻ヘッダも 1 つ。 ただしユーザー追加コメント:

「両方のいいところ取る方法ないかなー」

ここで自分は安易に「Z で十分」 と返してしまいがちですが、 ユーザー側がまだ何か気にしている = まだ核心に到達してない。 もう一段深掘り:

「スクロールしながら DnD するのが大変だなーとおもってねー」

→ 核心問題が見えました。 部屋が多いと下までスクロール して目的のルームを確認、 上にスクロールバック して未アサインキャストを掴み、 また下にドラッグ + スクロール。 1 キャスト 2 往復の労力。

案 D-1: sticky overlay + HTML5 DnD

3 周目で着地した案:

  • 未アサインを タイムライン外の sticky overlay として描画 (時間軸はシェア)
  • バーは HTML5 native draggable、 部屋エリアに onDrop ハンドラ
  • ドロップ位置から (room, time) を逆算 → 既存の確定ダイアログを呼ぶ
  • 部屋 → 未アサインに戻すは tooltip 内のボタンから (誤操作防止に確認ダイアログ)
{isVisit && unassignedItems.length > 0 && (
  <div style={{
    position: "sticky",
    top: 0,
    zIndex: 1250,
    height: visitOverlayHeight,
  }}>
    {/* 未アサインバー: HTML5 draggable */}
  </div>
)}
<div ref={timelineWrapperRef}
     onDragOver={(e) => e.preventDefault()}
     onDrop={(e) => {
       const shiftId = e.dataTransfer.getData("text/plain");
       const target = decodeDropPosition(e);
       setPendingRoomMove({ ... });
     }}>
  <Timeline groups={roomGroups} items={roomItems} ... />
</div>

未アサインがいつも上端に固定されてるので、 部屋まで page スクロールしても掴める = 1 キャスト 1 往復 で済む。 12 名割当作業が体感半分以下に。

Before / After: 案 Z = 1 キャスト 2 往復スクロール × 12 名 = 24 往復。 案 D-1 = 1 往復 × 12 名 = 12 往復。 体感作業時間は約半分。

学び: ユーザーの不満は 1 段階深く

「両方のいいところ取りたい」 だけだと案 Z で止まる。 「スクロール往復が辛い」 まで掘れて初めて sticky overlay という解決策が出てくる。

「もう少し」 と言われた時、 開発者の癖で 解釈 してしまわずもう 1 つ質問するだけで、 設計の方向性が全然違ってくる、 という経験でした。

2. 受注画面の cascade フィルタ: 店舗 → クラス → キャスト → ルーム

visit 業態では受注画面のフィールド構成が delivery と違います。 不要なフィールドを isVisit 分岐で非表示、 必要なフィールドを追加する作業。

非表示にしたフィールド

  • エリア
  • ホテル
  • 住所
  • 出発時刻
  • 交通費 / ホテル代

これらは「送迎」 が前提のデリバリー型固有なので、 来店型では一切使わない。

追加した cascade フィルタ

店舗 → クラス → キャスト → ルーム

店舗を必ず先に選ぶ運用なので、 visit のときだけ並び順を強制:

  • 店舗 Select → 全店舗から選択
  • クラス Select → 店舗未選択時 disabled、 選択時はその店舗のキャストが持つクラスのみ表示
  • キャスト Autocomplete → 店舗 + クラス でフィルタ + 「shift.roomId が set されてる」 cast のみ (= 朝の DnD で部屋振り済)
  • ルーム Select → 選択店舗の部屋のみ表示、 cast 選択時に castShift.roomId から 自動 fill

実装は OrderInfo の JSX を isVisit 分岐で書き換え、 cast の onChange ハンドラで castShifts を引いて roomId を auto-set。

onChange={(event, value) => {
  // ... 既存処理 ...
  if (isVisit && value?.castNameId) {
    const cId = (value as any).castId;
    const shift = castShifts?.find(
      (s: any) => s.cast?.castId === cId && s.roomId != null
    );
    if (shift?.roomId) {
      onChangePrice("roomId")(shift.roomId);
    }
  }
}}

「1 ルーム × 時間帯複数 cast 入る運用 (例: 201 = 10-15 あけみ / 16-24 ゆうこ)」 も castShift 単位 (cast + 時間 + room) で表現できるので、 単純な「has roomId」 判定で OK。

cleaning_end_time カラム追加

受注完了時、 planOutTime + cast.cleaningIntervalMincleaning_end_time カラム に保存。 次の予約は本時刻以降のみ可能、 という運用上のロックポイント。

// server/src/api/staff/order/service.ts
if (req.roomId && req.planOutTime && req.castNameId) {
  const castNameWithCast = await castNameRepository.findOne(...);
  const cim = castNameWithCast?.cast?.cleaningIntervalMin ?? 0;
  createOrder.cleaningEndTime = DateTime.fromJSDate(req.planOutTime)
    .plus({ minutes: cim })
    .toJSDate();
}

将来的に「清掃中バー」 (planOutTimecleaning_end_time の半透明バー) をキャストスケジュール上に描画して、 部屋次予約の判断材料に。

Before / After: cleaning_end_time なし → オペレーターが頭の中で「+15 分」 計算 = 1 受注あたり約 5 秒の判断遅延 + 計算ミス時の二重予約リスク。 自動算出後 = 0 秒。

3. 「1 週間先予約はどうしてる?」 でロジック分岐を確定

cast filter のロジックを詰めてる時、 ある時点で開発者の妄想が混じり始めました:

  • A) 「cast は必ず部屋振り済」 → 厳しいフィルタ
  • B) 「未来予約は cast が未アサインでも OK」 → 緩いフィルタ
  • C) 別管理キュー

判断材料が足りない時、 ユーザーに 4 つの質問を送ってもらいました:

  1. メンズエステの予約の際に、 1 週間先とかの予約ってどうしてるんでしょうか?
  2. すでにルームは決まってますか?
  3. ルームも決まっておらず、 予約だけ入れますか?
  4. 予約の段階でルームを決めますか?

このうち回答が来た時点で、 ロジック分岐は自動的に決まります。 開発者側で 3 つの分岐を全部実装しなくていい (= 妄想ロジック削減)。

「コールセンターでの実運用を言語化してもらう」 のは、 実装より先に必ずやる。 仕様の妥協が減るし、 後付け再実装も避けられる。

Before / After: 開発者妄想で 3 分岐実装 = 推定 8 時間 + 後で 2/3 削除する作業負担。 質問 1 回 (4 サブ質問) で 2/3 の実装をスキップ = 純粋に 5-6 時間の節約。

4. Day 21 PR 数値 (Build-in-Public)

指標Day 21 朝Day 21 22:00Δ
X follower6269+7
following175204+29
posts343369+26

3 Cron 構成 (12:30 / 19:30 / 22:00) で 1 日 / 3 軸 (post / quote-RT / 深 reply) を回し、 follower +7。 visit 開発と PR を 1 セッション内で並行運用する形は Day 18 以降の定型。

5. 残課題: 1 週間先予約フローの仕様待ち

オーナー回答が来たら、 cast filter のロジック分岐 (厳/緩) を確定して Day 22 に仕上げ + 本番マージ準備に入る予定。 Phase 2.5 (メッセージング基盤) は本番化後。

5/20 月 本番リリース照準は変わらず、 Codex MCP に「Day 22 優先順は?」 と相談したら 「Phase 2.3 仕上げ + 本番マージ準備 > PR 維持 > Phase 2.5」 という推奨で、 同じ判断に着地。

6. 次回使える判断テンプレ (3 ステップ)

業界特化 SaaS の機能追加 / 既存機能改修で迷ったら、 以下を順に踏む:

Step 1: 質問 (実装より先に必ず)

  • オペレーション実運用を 4 つ以下の具体質問で言語化してもらう
  • 例: 「1 週間先予約はどうしてる?」 「ルーム振りはいつ?」
  • → 開発者妄想で 3 分岐を全実装する事故を回避

Step 2: 分岐 (回答を分岐ロジックにマップ)

  • ユーザー回答から「実装すべき 1 経路」 を確定
  • 不要分岐は実装しない (後で必要になったら追加、 YAGNI)

Step 3: 実装 (Before / After 数値を持って検証)

  • 完成後、 「Before = 旧運用」 「After = 新運用」 で 1 指標 (時間 / クリック数 / ミス率) を測る
  • 数値が改善してなければ設計に戻る

このテンプレで Day 21 は visit Phase 2.3 (シフト配置 UI + 受注画面) を 1 日で QA まで持っていけました。


おまけ: 「ユーザーの不満を 1 段階深く掘る」 「オペレーション質問で妄想ロジックを排除」 は、 業界特化 SaaS で機能数を増やすより既存機能を磨くフェーズで効きます。 機能追加より「捨てる判断」 のほうが累積の体験品質に効く、 という年間 8 年運用の感覚は別の記事で。

30日無料トライアル実施中

現場を、もっと軽やかに。
tasteck で始めましょう。

お申込みは 3 分で完了。カード登録なし、電話番号認証のみ。

  • カード登録なしで開始
  • データ移行サポート無料
  • 全機能 30 日間お試し