Reactで、週ごとの予定を時間割のように表示する方法

​​React​CSS Grid,Playwright,TypeScript

週ごとの予定を時間割のように見せるには、月曜から日曜の7日を列、時刻を行にした CSS Grid を作ります。行を5分ずつに区切っておけば、「10:30〜12:00」の予定は、開始と終了の時刻を行の線の番号に直して grid-row に書くだけで、正しい高さで置けます。時間が重なる予定は、横に並べる位置(レーン)に振り分け、日の列を等分して並べます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、週の計算とレーンの振り分けは Node.js で、画面は Playwright で Chromium を操作して確かめました。Firefox と Safari、スマートフォンの実機、スクリーンリーダーでの読み上げは試していません。日付をまたぐ予定、終日の予定、ドラッグで予定を動かす操作は扱いません。

時間割の画面。上に「前の週」「今週」「次の週」のボタンと「11/2 からの週」の表示がある。左に 8:00 から 16:00 までの時刻、右に 11/2(月)から 11/5(木)までの列が並び、11/4(水)の見出しは水色の背景になっている。11/4 には 10:00 の定例の打ち合わせ、10:30 のレビュー、11:00 の電話が3つ横に並び、幅が狭いので「定例の打ち合わせ」は名前の最後の行が切れている。11/5 には 14:00 の発表の練習と 15:00 の日報が左に縦に並び、14:30 の面談が右に並んでいる。30分の日報は高さが足りず、名前の下半分が切れている
検証用の時間割の、月曜から木曜、8時から16時の部分。時間が重なる予定は横に並ぶ

週の月曜日を、日数の計算で求める

時間割に出す7日は、「表示する週の月曜日」を1つ決めれば、そこから1日ずつ足して作れます。日付は、Date.UTC で「1970年1月1日から何日たったか」という整数に直して扱います。Date.UTC は年・月・日を UTC として扱うので、ブラウザのタイムゾーンで日付がずれません。

ts
const DAY = 24 * 60 * 60 * 1000;

// 'YYYY-MM-DD' を、1970-01-01 から数えた日数にする(タイムゾーンの影響を受けない)
export function toDay(date: string): number {
  const [y, m, d] = date.split('-').map(Number);
  return Date.UTC(y, m - 1, d) / DAY;
}

// 日数から、月・日・曜日(0 が日曜)を取り出す
export function dayParts(day: number) {
  const dt = new Date(day * DAY);
  return { month: dt.getUTCMonth() + 1, date: dt.getUTCDate(), weekday: dt.getUTCDay() };
}

// その日を含む週の月曜日。日曜日は前の週の終わりとして扱う
export function mondayOf(day: number): number {
  const { weekday } = dayParts(day);
  return day - ((weekday + 6) % 7);
}

// 'HH:MM' を、0時からの分数にする
export function toMinutes(time: string): number {
  const [h, m] = time.split(':').map(Number);
  return h * 60 + m;
}

getDay() の説明にあるとおり、曜日の番号は日曜が 0 で始まります。月曜始まりの週にするには、(weekday + 6) % 7 で「月曜が 0、日曜が 6」の番号に直してから、その日数だけ戻します。曜日の番号を引いて1を足すだけの式にすると、日曜日は 0 なので1日先へ進み、次の週の月曜日になってしまいます。Node.js で試した結果です。

text
2026-11-02 → 週の月曜 11/2(月)
2026-11-04 → 週の月曜 11/2(月)
2026-11-08 → 週の月曜 11/2(月)
2026-11-09 → 週の月曜 11/9(月)
2026-03-08 → 週の月曜 3/2(月)
2026-11-01 → 週の月曜 10/26(月)
2026-11-08 → 曜日の番号を引いて1を足すと 11/9(月)

この結果は、環境変数 TZ を Asia/Tokyo、UTC、America/New_York に変えても同じでした。比べるために出した new Date('2026-11-08').getDay() は、America/New_York だけ 6(土曜)になりました。MDN の Date の説明にあるとおり、日付だけの文字列は UTC の0時として読まれ、getDay() は現地時間(ニューヨークでは前の日の夜)で曜日を返すためです。

5分を1行にしたグリッドに、予定を置く

時間割のコンポーネントです。表示する週の月曜日(weekStart)と、表に出す時間帯(既定は8時から20時)を受け取ります。1列目を時刻、2列目から8列目を月曜から日曜の列にし、1行目を曜日の見出しにしています。

tsx
import type { CSSProperties } from 'react';
import { assignLanes } from './lanes';
import { dayParts, toDay, toMinutes } from './week';

export type Plan = { id: string; title: string; date: string; start: string; end: string };

type Props = {
  events: Plan[];
  weekStart: number; // 週の月曜日(toDay の値)
  today: number;
  from?: number; // 表に出す最初の時刻(分)
  to?: number; // 表に出す最後の時刻(分)
};

const STEP = 5; // 1行 = 5分
const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
const hhmm = (min: number) => `${Math.floor(min / 60)}:${String(min % 60).padStart(2, '0')}`;

export function Timetable({ events, weekStart, today, from = 8 * 60, to = 20 * 60 }: Props) {
  const days = Array.from({ length: 7 }, (_, i) => weekStart + i);
  const hours = Array.from({ length: (to - from) / 60 }, (_, i) => from + i * 60);
  // 時刻(分)を、グリッドの行の線の番号にする。1行目は曜日の見出し
  const line = (min: number) => (min - from) / STEP + 2;

  const grid: CSSProperties = {
    gridTemplateColumns: `3.5em repeat(7, minmax(120px, 1fr))`,
    gridTemplateRows: `auto repeat(${(to - from) / STEP}, 4px)`,
  };

  return (
    <div className="tt-scroll">
      <div className="tt" style={grid}>
        {days.map((day, i) => {
          const { month, date, weekday } = dayParts(day);
          return (
            <div
              key={day}
              className={day === today ? 'tt-day today' : 'tt-day'}
              style={{ gridColumn: i + 2, gridRow: 1 }}
            >
              {month}/{date}({WEEKDAYS[weekday]})
            </div>
          );
        })}
        {hours.map((h) => (
          <div key={h} className="tt-hour" style={{ gridColumn: '1 / -1', gridRow: `${line(h)} / span ${60 / STEP}` }}>
            <span>{hhmm(h)}</span>
          </div>
        ))}
        {days.map((day, i) => {
          const spans = events
            .filter((e) => toDay(e.date) === day)
            .map((e) => ({
              id: e.id,
              title: e.title,
              label: `${e.start}〜${e.end} ${e.title}`,
              shown: hhmm(toMinutes(e.start)), // 画面には元の開始時刻を出す
              // 表の時間帯からはみ出す部分を切り落とし、5分単位に広げる
              start: Math.floor(Math.max(toMinutes(e.start), from) / STEP) * STEP,
              end: Math.ceil(Math.min(toMinutes(e.end), to) / STEP) * STEP,
            }))
            .filter((e) => e.start < e.end);
          return assignLanes(spans).map((e) => (
            <div
              key={e.id}
              className="tt-event"
              style={{
                gridColumn: i + 2,
                gridRow: `${line(e.start)} / ${line(e.end)}`,
                // 重なった予定は、日の列を lanes 等分して横に並べる
                width: `calc(${100 / e.lanes}% - 4px)`,
                marginLeft: `calc(${(100 * e.lane) / e.lanes}% + 2px)`,
              }}
              title={e.label}
            >
              <span className="tt-time">{e.shown}</span>
              {e.title}
            </div>
          ));
        })}
      </div>
    </div>
  );
}

行の高さは 4px なので、1時間(12行)が 48px になります。grid-row には行と行の間の線の番号を「始まりの線 / 終わりの線」の形で書きます。8:00 の線は見出しの下の、2 番目の線なので、line は「8:00 から何分後か ÷ 5 + 2」です。10:30〜12:00 の予定なら 32 / 50 になります。

時刻の目盛りは、1時間ごとの要素を gridColumn: '1 / -1' で全部の列にまたがるように置き、上の線(border-top)を罫線として使っています。CSS Grid の仕様の重なり順の節にあるとおり、z-index を指定しないグリッドアイテムは HTML に書いた順に描かれます。目盛りを予定より先に書いているので、重なったところでは予定が上に描かれます。

Chromium で、8:00 の線から各予定の上端までの距離と高さを測った結果の一部です。どれも「分 × 48 ÷ 60」の px になりました。

text
9:00朝の勉強会|列 0|8:00 からの y 48.0px(60分)高さ 48.0px(60分)|列内の左端 2.0px 幅 116.0px(列の 96.7%)
13:00資料づくり|列 1|8:00 からの y 240.0px(300分)高さ 120.0px(150分)|列内の左端 2.0px 幅 116.0px(列の 96.7%)
7:30早朝ラン|列 4|8:00 からの y 0.0px(0分)高さ 48.0px(60分)|列内の左端 2.0px 幅 116.0px(列の 96.7%)
19:00映画|列 5|8:00 からの y 528.0px(660分)高さ 48.0px(60分)|列内の左端 2.0px 幅 116.0px(列の 96.7%)
10:12買い物|列 6|8:00 からの y 104.0px(130分)高さ 32.0px(40分)|列内の左端 2.0px 幅 116.0px(列の 96.7%)

7:30〜9:00 の早朝ランは、表の時間帯より前の部分を切り落として 8:00〜9:00 に置きました。19:00〜21:00 の映画も、20:00 で切れています。10:12〜10:48 の買い物のように、5分で割り切れない時刻は、開始を切り捨て、終了を切り上げて、10:10〜10:50 の枠に広げます。grid-row の説明にあるとおり、線の番号は整数で指定するので、5分単位にそろえています。予定の中に出す時刻は、丸める前の e.start から作った shown なので、画面には「10:12」「7:30」と元の時刻が出ます。title 属性には「10:12〜10:48 買い物」のように、終わりの時刻まで入れました。

重なった予定は、レーンに振り分けて横に並べる

同じ日の同じ時間帯に予定が2つあると、何もしなければ同じマスに重なります。前の節で触れた描画の順のとおり、後に書いたほうが前のほうを隠します。そこで、予定を開始の早い順に見ていき、空いている「レーン」(縦の通り道)に振り分けます。

ts
// 同じ日の予定を、重なりのない「列(レーン)」に振り分ける
export type Span = { id: string; start: number; end: number };
export type Placed<T> = T & { lane: number; lanes: number };

export function assignLanes<T extends Span>(items: T[]): Placed<T>[] {
  const sorted = [...items].sort((a, b) => a.start - b.start || b.end - a.end);
  const result: Placed<T>[] = [];
  let group: Placed<T>[] = []; // 重なりでつながっている予定のまとまり
  let laneEnds: number[] = []; // レーンごとの、最後に置いた予定の終わり
  let groupEnd = -Infinity;

  const closeGroup = () => {
    for (const p of group) p.lanes = laneEnds.length;
    result.push(...group);
    group = [];
    laneEnds = [];
  };

  for (const item of sorted) {
    // まとまりのどの予定とも重ならなければ、そこで区切る
    if (item.start >= groupEnd) closeGroup();
    // 空いている(前の予定が終わっている)いちばん左のレーンに置く
    let lane = laneEnds.findIndex((end) => end <= item.start);
    if (lane === -1) lane = laneEnds.length;
    laneEnds[lane] = item.end;
    group.push({ ...item, lane, lanes: 0 });
    groupEnd = Math.max(groupEnd, item.end);
  }
  closeGroup();
  return result;
}

lanes は、その予定が属する「まとまり」で使ったレーンの数です。まとまりは、重なりでつながっている予定の集まりで、どの予定とも重ならない予定が来たところで区切ります。こうすると、離れた時間の予定まで細くなることはありません。検証用のデータで振り分けた結果です。

text
[11/4] 定例: lane 0/3、レビュー: lane 1/3、電話: lane 2/3、ランチ: lane 0/1
[11/5] 練習: lane 0/2、面談: lane 1/2、日報: lane 0/2
[連続] A: lane 0/1、B: lane 0/1

11月4日の定例(10:00〜11:30)、レビュー(10:30〜12:00)、電話(11:00〜11:45)は、互いに重なるので3つのレーンに分かれました。12:30 からのランチは、どれとも重ならないので別のまとまりになり、列の幅をすべて使います。11月5日の日報(15:00〜15:30)は、14:00〜15:00 の発表の練習が終わったレーン 0 に入りました。9:00〜10:00 と 10:00〜11:00 のように、終わりと始まりがちょうど同じ予定は重なりとみなさず、同じレーンに縦に並びます。

レーンの番号は、width と margin-left に直して使います。CSS Grid の仕様によると、グリッドアイテムのパーセント指定の余白は、そのアイテムが置かれたグリッド領域を基準に計算されます。そのため margin-left を 0%、33.3%、66.7% とすれば、日の列の幅の3分の1ずつずれます。幅も列の3分の1にすると、3つの予定が横に並びます。コードでは、隣の予定との間に隙間を作るため、幅から 4px を引き、左の余白に 2px を足しています。CSS では justify-self: start で、予定を列の左端に揃えています。

css
.tt-scroll { overflow-x: auto; }
.tt { display: grid; font-size: 12px; }
.tt-day { padding: 4px 0; text-align: center; border-bottom: 1px solid #9aa5ab; }
.tt-day.today { background: #eefafd; color: #067a98; font-weight: bold; }
.tt-hour { border-top: 1px solid #dde3e6; }
.tt-hour span { display: inline-block; width: 3.5em; padding-right: 4px; text-align: right; color: #5b666c; transform: translateY(-50%); background: #fff; box-sizing: border-box; }
.tt-event {
  justify-self: start;
  box-sizing: border-box;
  overflow: hidden;
  padding: 2px 4px;
  border-left: 3px solid #067a98;
  border-radius: 4px;
  background: #c8f3fa;
  line-height: 1.3;
}
.tt-time { display: block; font-size: 11px; color: #067a98; white-space: nowrap; }

幅 120px の列で測ると、3レーンの予定は幅 36.0px で、列の左端から 2.0px、42.0px、82.0px の位置に並びました。2レーンの予定は幅 56.0px で、2.0px と 62.0px です。

3レーンに分かれると、名前が収まりません。記事の最初の画像でも、「定例の打ち合わせ」は2文字ずつで折り返し、最後の行が予定の下の端で切れています。title 属性に時刻と名前を入れていますが、title 属性の説明には、タッチ操作だけの機器を使う人や、キーボードで操作する人には伝わりにくいと書かれています。同じページでは、ツールチップのような表示が必要なら、そうした人も使える別の方法にするよう勧めています。

週を切り替えるボタンと、スマートフォンの幅

表示する週は、月曜日の日数を state に持ち、ボタンで7ずつ増やしたり減らしたりします。「今週」のボタンは、今日を含む週の月曜日に戻します。検証では、結果が毎回同じになるように、今日を 2026年11月4日に固定しました。

tsx
// import と、予定のデータ(events)は省略
const TODAY = toDay('2026-11-04');

export function App() {
  const [weekStart, setWeekStart] = useState(() => mondayOf(TODAY));
  const { month, date } = dayParts(weekStart);
  return (
    <main>
      <div className="toolbar">
        <button onClick={() => setWeekStart((d) => d - 7)}>‹ 前の週</button>
        <button onClick={() => setWeekStart(mondayOf(TODAY))}>今週</button>
        <button onClick={() => setWeekStart((d) => d + 7)}>次の週 ›</button>
        <span className="range">{month}/{date} からの週</span>
      </div>
      <Timetable events={events} weekStart={weekStart} today={TODAY} />
    </main>
  );
}

events には、2026年11月2日から9日までの13件の予定を入れています。Playwright でボタンを押した結果です。

text
次の週 → 11/9 からの週 | 11/9(月) 11/10(火) 11/11(水) 11/12(木) 11/13(金) 11/14(土) 11/15(日) | 予定: 9:00翌週の勉強会 | 今日の列の数: 0
前の週を2回 → 10/26 からの週 | 10/26(月) 10/27(火) 10/28(水) 10/29(木) 10/30(金) 10/31(土) 11/1(日) | 予定の数: 0 | 今日の列の数: 0
今週 → 11/2 からの週 | 予定の数: 12 | 今日の列: [ '11/4(水)' ]

月をまたぐ 10/26〜11/1 の週も、見出しの日付は続いて並びました。今日の日付の見出し(水色の背景)は、次の週と2週前の週では出ず、「今週」で戻ると 11/4(水)にまた出ました。

日の列には minmax(120px, 1fr) で最小幅を付けたので、時間割を置く場所の幅が、時刻の列と合わせて 882px より狭いと収まりません。そこで、グリッドを overflow-x: auto の要素(.tt-scroll)で包み、時間割の部分だけを横にスクロールさせます。画面の幅を 390px にして測ると、.tt-scroll は見えている幅 366px に対して中身の幅が 882px で、横にスクロールできました。ページ全体の幅は 390px のままで、ページごと横にずれることはありませんでした。

​Posted by 86