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.cleaningIntervalMin を cleaning_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();
}
将来的に「清掃中バー」 (planOutTime 〜 cleaning_end_time の半透明バー) をキャストスケジュール上に描画して、 部屋次予約の判断材料に。
Before / After: cleaning_end_time なし → オペレーターが頭の中で「+15 分」 計算 = 1 受注あたり約 5 秒の判断遅延 + 計算ミス時の二重予約リスク。 自動算出後 = 0 秒。
3. 「1 週間先予約はどうしてる?」 でロジック分岐を確定
cast filter のロジックを詰めてる時、 ある時点で開発者の妄想が混じり始めました:
- A) 「cast は必ず部屋振り済」 → 厳しいフィルタ
- B) 「未来予約は cast が未アサインでも OK」 → 緩いフィルタ
- C) 別管理キュー
判断材料が足りない時、 ユーザーに 4 つの質問を送ってもらいました:
- メンズエステの予約の際に、 1 週間先とかの予約ってどうしてるんでしょうか?
- すでにルームは決まってますか?
- ルームも決まっておらず、 予約だけ入れますか?
- 予約の段階でルームを決めますか?
このうち回答が来た時点で、 ロジック分岐は自動的に決まります。 開発者側で 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 follower | 62 | 69 | +7 |
| following | 175 | 204 | +29 |
| posts | 343 | 369 | +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 年運用の感覚は別の記事で。