`` では 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> に置き換えます。

  1. 時刻入力を 30 分刻みの <select> に置換する
  2. 時刻の内部表現を「分換算値」に統一する
  3. 終了時刻の選択肢を「開始より後 かつ 勤務 24 時間未満」で動的にフィルタする
  4. 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 時間で閉じない」という前提を最初に置くと、その後の設計が一気に楽になります。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。