GoogleカレンダーをWebで再現してみた

LT会で発表する機会があったので、資料を共有します。気になる方はこちらもご覧ください。

https://docs.google.com/presentation/d/1AeuZ2mC0EEnswsvfDqey8gLK-AVt-xfvNf9KRuWoo1Y/edit?usp=sharing

## 作品の概要

はじめに作品を見てもらいましょう。

### ドラッグでのカレンダー追加

![3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/1f1bc7b8-b038-dccf-2d80-83b08bb3c75b.gif)

### ドラッグでのカレンダー移動

![2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/f6443e92-e7ec-3966-de2d-8e9ccd1cc194.gif)

### ドラッグでのカレンダー時刻拡張

![1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/79f7ab17-ce40-5412-6c30-909bef00c2eb.gif)

### ダイアログによるカレンダー編集

![スクリーンショット 2024-09-22 8.27.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/8c572ce6-4335-5c35-4761-5ecf0282955f.png)

### カスタマイズ可能なカレンダー表示

一度に表示する量を変更できます。

![スクリーンショット 2024-09-22 9.49.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/8de21c78-0457-2523-b871-72c5145d68ef.png)

### Googleカレンダー連携

マイカレンダー一覧で、表示・非表示を選択できます。言い忘れましたが、Googleアカウント連携機能もあり、アカウントのカレンダーが自動取得されます。

![スクリーンショット 2024-09-22 8.30.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/276788c5-7f5f-245b-7873-fe0f42b34b43.png)

https://sub9.fast5-blog.com

## カレンダー再現の動機づけ

インターンでシフト入力をより便利なものにしよう！というのが始まりでした。

![スクリーンショット 2024-09-25 5.46.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/597a1be6-3cf8-53b5-a722-efac3b7d6bc0.png)

現状のシフト入力は、スプレッドシートを用いており、自身の列に時刻を入力する方式です。しかし、問題点が複数あります。

- 自身の確認不足で自分の予定と重なってしまう
- 誤って他人の列に入力してしまう
- 自身のカレンダーに再度入力が必要
- スプレッドシートだけではシフトがあるか否かをパット見で判断できない

どうしてもヒューマンエラーによるものが多く、情報技術の力でこれを減らしたいのです。

![スクリーンショット 2024-09-25 5.59.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/49f8589f-e75c-ecef-54db-989bf5775272.png)

作成途中でありますが、こちらがインターンで作成してるシフト提出画面です。こちらも同様にドラッグ操作が可能で、かつ、自身のGoogleアカウントに基づいてGoogleカレンダーの予定を表示しています。

また、将来的にはシフトに記述したカレンダーを同期する処理も作成する予定なので、ヒューマンエラーの解消に大きく貢献するでしょう。

![スクリーンショット 2024-09-25 6.02.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/4bbb48dc-6d43-e4e8-8a13-1251835a0487.png)

設定でアカウントの追加、マイカレンダーの表示オプションを選択できます。

:::note info
インターンのシステムはバックエンド付きなので、複数アカウント対応です。
その他、シフト入力だけなのでドラッグ操作の一部省略などをしています。
:::


## 機能一覧

- カレンダー追加機能
- カレンダー編集機能
- カレンダー削除機能
- 設定機能
  - カレンダー同期の項目（アカウント連携で一覧表示）
  - ドラッグ時の１時間あたりの分割数
  - １時間あたりの高さ
  - １画面の週の表示数
  - カレンダーの開始タイミング

いずれもローカルストレージに保存され、同デバイス内で永続的に保存されます（カレンダーの追加・編集・削除はアカウント連携時のみ対応）。

## 技術スタック

![スクリーンショット 2024-12-07 15.48.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/6a118a4d-075b-dd91-97d0-381cc3751fd8.png)

- フロントエンド：`React`＋`Vite`＋`TypeScript`
- API：`Google Cloud`（アカウント認証）, `Google Calendar API`
- デプロイ：レンタルサーバー（CI/CD GitHub）
- その他
    - ローカルストレージを使用

今回は独立したバックエンドストレージを保有していないため、設定項目のデータはローカルストレージを使用してデータを格納する方式にしています。

## 今回のメインはドラッグ操作

今回のメインと言っても過言でないのが、ドラッグ操作だと思います。既にあるものを再現したわけですが、構造としてはシンプルです。

### MouseDown, Move, Upイベントで取得

![スクリーンショット 2024-09-22 10.10.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/0c7c3429-0884-d96b-7d2f-3dc48843d837.png)

- `MouseDown`: マウスが要素内で押された
- `MouseMove`: マウスが要素内で動いた
- `MouseUp`: マウスが要素内で放された

この３つのイベントを取得して、ドラッグした範囲を取得しています。今回はReactを使用しているので、useStateでその情報を保持します。

```tsx
import React from 'react';

export const DragTest: React.FC = () => {
  // ドラッグ中かどうかの状態
  const [isDragging, setIsDragging] = React.useState<boolean>(false);
  // ドラッグ中の要素の位置
  const [position, setPosition] = React.useState<{ x: number; y: number }>({
    x: 0,
    y: 0,
  });

  // マウスダウン時の処理
  const handleMouseDown = (e: React.MouseEvent) => {
    setIsDragging(true);
    setPosition({ x: e.clientX, y: e.clientY });
  };

  // マウスムーブ時の処理
  const handleMouseMove = (e: React.MouseEvent) => {
    if (isDragging) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  };

  // マウスアップ時の処理
  const handleMouseUp = () => setIsDragging(false);

  return (
    <div>
      <div
        style={{
          width: '100%',
          height: '100svh',
          border: '1px solid black',
          position: 'relative',
        }}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
      >
        <div
          style={{
            width: 100,
            height: 100,
            backgroundColor: 'blue',
            position: 'absolute',
            top: position.y,
            left: position.x,
          }}
          onMouseDown={handleMouseDown}
        />
      </div>
    </div>
  );
};

```

![3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/53e60fba-6c72-8197-6adf-1e98b40de5c3.gif)

これだけでオブジェクトを動かすことは可能です。ただ実際は日付・時刻ごとに予定追加できるように実装するため、より複雑な処理・計算をする必要があります。

## ドラッグ位置をindex値として計算

![スクリーンショット 2024-09-23 10.36.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/08d31bde-b0d5-e09c-4caf-b86b35934fb3.png)

私が開発した処理は、左上を基準としてindex値でマウス位置を取得しています。

```ts
export const getMouseSelectedCalendar = (
  e: React.MouseEvent<HTMLElement>, // カレンダー本体
  scrollBase: HTMLElement,
  topHeight: number // カレンダーの日付上部の高さ
) => {
  const rect = e.currentTarget.getBoundingClientRect();
  const nowLeftPosition = e.clientX - rect.left - LEFT_WIDTH; // 現在の左位置
  const xIndex = Math.floor((nowLeftPosition / (rect.width - LEFT_WIDTH)) * 7);

  if (e.clientY < rect.top + topHeight) {
    // 終日予定はyIndexを -1 とする
    return { xIndex, yIndex: -1 };
  }
  const nowTopPosition = e.clientY - rect.top - topHeight + scrollBase.scrollTop; // 現在の上位置

  // 高さの index を取得（0~23）
  const yIndex = Math.floor(nowTopPosition / 40); // 40は1時間あたりの高さ

  return { xIndex, yIndex };
};

const { xIndex, yIndex } = getMouseSelectedCalendar(e, scrollRef.current!, topHeight);
```

### マウスイベントはブラウザ左上からの位置

`e.clientX`, `e.clientY`が、画面上のマウスの位置です。これは**ブラウザの左上からの座標**を示しているので、実際のカレンダーの位置は、`topHeight`, `LEFT_WIDTH`などを考慮して計算する必要があります。

さらに、カレンダーの0:00以降の部分は**内部でスクロール可能**となっています。マウスの座標がそのまま、でも要素の位置は動きます。それも考慮して`scrollBase.scrollTop`を引き算しています。

ちなみに**上部の日付・曜日が記載の部分もドラッグ範囲**であり、終日のイベントもドラッグしてイベントの追加が可能です。

### 時間はbaseからindexを足すことで求められる

index情報から時間に変換するときも、baseの日付（画像だと`2024/09/22`）からindexの分を足すことで求められます。

私が今回利用したのは、`dayjs`というライブラリです。通常のDate関数よりもformat系の変換が初期で備わっていて便利です。

```ts
const baseDate = dayjs('2024-09-22');

console.log(baseDate.add(1, 'day')); // 2024-09-23（実際はオブジェクト）
console.log(baseDate.startOf('day')); // 2024-09-22 00:00（実際はオブジェクト）
console.log(baseDate.endOf('day')); // 2024-09-22 23:59（実際はオブジェクト）
```

## onMouseDownの要素によって処理を変更

今回はカレンダーの追加だけでなく、既存のカレンダー編集、移動、拡張なども対応する必要があります。

### onMouseDownはonClickより早く応答する

仮に既存のカレンダーに`onClick`属性を付与しても、対象が`onMouseDown`の範囲だと、**`onMouseDown`が優先的に動作**します。そのため、`onMouseDown`にクリック時の処理を記述する必要があります。

:::note info
子要素に`onMouseDown={(e)=> e.stopPagination()}`を付与することで、コンテナ側の`onMouseDown`を動作させないことも可能です。私の場合は既存イベントの移動、拡張も考慮しているためこの方法は使用していません。
:::

### 既存イベントか否かはidで判別

```tsx
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
    // イベントの上でマウスダウンした場合の処理
    if (e.target.id.includes('calendarEvent__')) {
        const eventId = e.target.id.replace('calendarEvent__', '');
        const event = calendarEvents.find((event) => event.id === eventId);
        // その後処理が続く…
    }
    // 通常のイベント追加処理
}

// ボタンのコンポーネント
<button id={`calendarEvent__${id}`}>
```

このようにすることで、既存のイベントか否かを判断することができます。

## ドラッグ中の表示

![スクリーンショット 2024-09-25 7.45.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538408/44733241-2777-f727-79e7-f4ef2752bb2f.png)

マウスでドラッグを実施すると、青背景の表示がされます。`useState`で定義したドラッグ中の`start`, `end`によって表示しています。

先ほど`index`で管理しているとは言いましたが、実際は時刻のオブジェクトをそのまま入れています。

### みんな大好き`position: absolute`

画像のように、時刻に合わせてきちんと位置が定められていると思います。これは、CSSの`position`の概念を使用することで定められた位置に設置することができます。

また、サイズもきちんと計算して求めています。

### 複数日にまたぐ場合の処理

画像のように複数日の表示に対応するにはさらにひと手間必要です。オブジェクトとしては１つですが、3日にまたぐ場合は一旦3つに分割して、それぞれで表示されるようにしています。

## Googleカレンダー連携

Google Cloudで、Google Calendar APIが提供されています。これを利用してGoogleカレンダーのデータを取得しています。認証はGoogle Cloudの2Authですね。

詳しくはドキュメントをご覧ください。フロントから叩く場合は、ログイン処理以外はトークン、APIキーなどの小難しいこと書く必要がないので比較的楽です。

https://developers.google.com/calendar/api/guides/overview?hl=ja

## 感想・こだわった点

ここからは感想ですね。元々ここまで作る予定なかったのですが、結果的に楽しくなったため自身で独立のリポジトリ作って作成していました。

### 可能な限り共通ロジック化

まずは共通ロジック化ですね。ただでさえ`onMouseDown`, `Move`, `Up`のロジックが大きくなりやすいですが、まずはこのイベント取得を１箇所のみにしたことで、マウスイベントがごちゃごちゃせずにメンテナンス性が高いコードに仕上げました。

その他、既存イベントの表示とドラッグ中イベントの表示についてもできるだけ共通ロジック化したり、マウス位置の取得を様々な場所で使用できるようにindex化したりしました。

### 徹底的なファイル分割

ファイル数は結構増えてしまいますが、ロジック、表示ごとにファイルが分かれているので、探すためのスクロール量がかなり削減できます。

１ファイルあたり**１００行程度**に抑えており、かつ内容も、基本的にファイル名に記載のことだけ実施するような形で実装しました。

### デザインはNotion, ChatGPTを参考に

デザインは基本白、黒だけで実装しています。どのようにすればおしゃれになるかを考えながら実装しました。

また、できるだけカレンダーのエリアが広くなるように、ヘッダ部分の高さを最適化、随時様々な大きさでプレビューをして表示が隠れないかなどをテストしました。

## まとめ

今後はもう少しコピーペーストの機能などもつけて、現状のカレンダーよりスムーズに予定が追加できるようになればいいと感じています。既存のカレンダーをもっとこうしてほしい、という要望ありましたら、いつでも受け付けています！