`` では 26:00 が入力できない — 深夜シフトの時間入力を select 化した話
概要
飲食・バーなどの深夜業態向けにシフト管理システムを開発していたとき、「22:00〜翌02:00」のような日跨ぎの夜間シフトをパターン登録できないというバグに遭遇しました。
原因はシンプルで、時間入力に使っていた <input type="time"> が ブラウザ標準仕様で 23:59 までしか受け付けない ためです。深夜業態では終了時刻を「26:00(翌2:00)」のように 24 時超え表記で扱いたいのに、そもそも入力する手段がありませんでした。
本記事では、この構造的な制約を <select> ドロップダウン化・時刻の分換算・動的フィルタリング・二重表記の4点で乗り越えた実装を段階的に紹介します。
環境
- フロントエンド: React + TypeScript
- 対象: シフトパターンの開始/終了時刻を入力するフォーム
- バックエンド: 開始時間
00:00〜23:30、終了時間00:00〜47:30を 30 分刻みで受け付ける API(24 時超え表記に対応済み)
発生した問題
「22:00 に始まって翌 02:00 に終わる」夜間シフトを登録しようとすると、終了時刻の欄に 26:00 を入れられませんでした。
02:00 と入れれば見た目は入りますが、それは「開始より前の時刻」になってしまい、日跨ぎとして解釈させる術がありません。かといって 26:00 と打つと、<input type="time"> はそれを無効値として弾きます。
原因:なぜ <input type="time"> では無理なのか
<input type="time"> は HTML の標準仕様上、受け付ける値の範囲が 00:00〜23:59 に固定されています。これは UA(ブラウザ)が実装する時刻ピッカーの仕様であり、max 属性や CSS で拡張できる類のものではありません。
<!-- max="26:00" 自体が無効値なので効かない --> <input type="time" max="26:00" />
つまり「24 時超えの時刻を入力する」というユースケースに対して、<input type="time"> は属性やスタイルでの回避が効かない構造的な壁を持っています。バックエンドが 47:30 まで対応していても、入り口の UI がそれを表現できなければ意味がありません。
対応方法
方針は「時刻を HH:MM という文字列としてではなく、深夜 0 時起点の分換算値(0〜47:59 → 0〜2879 分)として扱う」ことです。UI は自前の <select> に置き換えます。
- 時刻入力を 30 分刻みの
<select>に置換する - 時刻の内部表現を「分換算値」に統一する
- 終了時刻の選択肢を「開始より後 かつ 勤務 24 時間未満」で動的にフィルタする
- 24 時以降の選択肢は「26:00(翌2:00)」と二重表記で表示する
実装例
分換算と二重表記のヘルパー
const toMinutes = (hhmm: string): number => {
const [h, m] = hhmm.split(":").map(Number);
return h * 60 + m;
};
const toLabel = (minutes: number): string => {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
};
// 26:00 以降は「(翌2:00)」を補足する二重表記
const toDisplayLabel = (minutes: number): string => {
const base = toLabel(minutes);
if (minutes >= 24 * 60) {
const next = toLabel(minutes - 24 * 60).replace(/^0/, "");
return `${base}(翌${next})`;
}
return base;
};
toDisplayLabel(1560) は "26:00(翌2:00)"、toDisplayLabel(1800) は "30:00(翌6:00)" を返します。内部値と人間の直感を両立させるのがポイントです。
選択肢の生成
const STEP = 30;
// 開始: 00:00〜23:30 の 48 個
const START_OPTIONS = Array.from(
{ length: (24 * 60) / STEP },
(_, i) => i * STEP
);
// 終了候補: 00:30〜47:30
const END_OPTIONS_ALL = Array.from(
{ length: (48 * 60) / STEP - 1 },
(_, i) => (i + 1) * STEP
);
終了時刻の動的フィルタ(肝)
const getEndOptions = (startMinutes: number): number[] =>
END_OPTIONS_ALL.filter(
(end) =>
end > startMinutes && // 開始より後
end - startMinutes < 24 * 60 // 勤務 24 時間未満(ちょうど 24h は不可)
);
開始 22:00(1320 分)を選ぶと、終了は 22:30(1350)〜45:30(2730)が選択可能になり、46:00(2760 = ちょうど 24 時間)は候補から外れます。
コンポーネント
const ShiftTimeRange: React.FC = () => {
const [start, setStart] = useState<number>(1320); // 22:00
const [end, setEnd] = useState<number>(1560); // 26:00
const endOptions = getEndOptions(start);
return (
<div>
<select value={start} onChange={(e) => setStart(Number(e.target.value))}>
{START_OPTIONS.map((m) => (
<option key={m} value={m}>{toLabel(m)}</option>
))}
</select>
〜
<select value={end} onChange={(e) => setEnd(Number(e.target.value))}>
{endOptions.map((m) => (
<option key={m} value={m}>{toDisplayLabel(m)}</option>
))}
</select>
</div>
);
};
バリデーションを分換算比較へ
// Before: 文字列比較(BE ルールと一致しない)
if (startTime >= endTime) { /* エラー */ }
// After: 分換算比較(BE の「勤務時間 < 24h」と同じ数式)
const duration = end - start;
if (duration <= 0) throw new Error("終了は開始より後にしてください");
if (duration >= 24 * 60) throw new Error("勤務時間は 24 時間未満にしてください");
BE は分換算で >= 24 * 60 を拒否します。FE も同じ式にすることで、「FE を通ったのに BE で弾かれる」というズレが消えます。
確認方法
- 開始
22:00を選び、終了に26:00(翌2:00)が出て選べること - 同じ状態で
46:00が候補に出ないこと(ちょうど 24 時間は不可) - 一覧表示が
22:00〜26:00と 24 時超え表記で出ること(一覧は BE が 24 時超表記で返すため FE 変換は不要)
注意点
既存データに 30 分刻みでない値が混じっていると詰みます。 過去に 09:15 のような値が保存されていると、<select> の選択肢にその値が存在せず、開いた瞬間に表示が壊れます。
<select> 化はドメインを 30 分グリッドに固定する強い制約なので、移行前に「刻みに乗らない既存データが無いか」のデータ調査は必須です。乗らないデータがあるなら、丸め方針を決めるか、当該レコードだけ別 UI を用意するなどの逃げ道を先に設計しておきます。
まとめ
<input type="time">は 00:00〜23:59 が上限で、深夜業態の 26:00 表記のような 24 時超え時刻は構造的に入力できない。CSS や属性では回避不可- 時刻を 0〜47:59 の分換算値として持つと日跨ぎの設計がきれいになる
- UI は 30 分刻みの
<select>、終了時刻は「開始より後 かつ 24 時間未満」で動的フィルタ - 表示は「26:00(翌2:00)」の二重表記で、内部値と人間の直感を両立
- FE バリデーションを分換算比較へ変えると、BE ルールと完全に一致する
- 移行時は「30 分刻みに乗らない既存データ」の調査を忘れずに
深夜帯を扱うドメインでは「時刻は 24 時間で閉じない」という前提を最初に置くと、その後の設計が一気に楽になります。
