【JavaScript】Stimulusでダッシュボードを改良してみた

# はじめに
前回の記事で稼働時間の計算とTodoを作成しました。

https://qiita.com/shoya_u/items/6fc1bde0e7a22a90d462

今回は、RailsのStimulusを使ってコードの改修をします。
タイトルを「ダッシュボードを改良」としていますが、「stimulus仕様にコードを書き換えた」という意味合いです。

# 完成図
見た目は前回とほぼ変わっていません。
![Cursor_と_Science_App.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/655fc3eb-907e-4720-ba3e-298132d5f82d.png)


# Stimulusとは？
Stimulusは、シンプルで軽量なJavaScriptフレームワークで、HTMLをベースにしたアプリケーションのインタラクティブな部分を簡単に構築するために使用されます。Rails 6以降では、デフォルトでStimulusが含まれています。

Stimulusは、HTML属性を使ってJavaScriptのコントローラーをHTML要素にバインドし、ユーザーの操作に応じて動作を定義します。これにより、HTMLとJavaScriptのコードが分離され、保守性が向上します。

https://stimulus.hotwired.dev/handbook/introduction


# 稼働時間計算処理の改修
### dasboard_controller.jsの作成
stimulusは`/javascript/controllers`配下に`example_controlelr.js`のような表記でjsファイルを配置します。

まずは、稼働時間計算の処理関係をstimulusに対応した形で書き換えるために、`dasboard_controller.js`を作成します。

```
$ rails generate stimulus dashboard
```

stimulusの雛形が書かれた状態で、jsファイルが生成されます。

```js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="dashboard"
export default class extends Controller {
}
```

### dashboard.html.erbにコードを追加

`<div data-controller="controllerName"/>`
接続するcontroller名をhtmlの一番外側に配置する
```html
<div data-controller="dashboard">
  <div class="mx-auto max-w-7xl min-h-screen bg-gray-200">
    <h1 class="p-2 text-2xl font-bold font-serif">Dashboard</h1>
  </div>
</div>
```

`data-dashboard-target="targetName"`
valueプロパティを読み取って、controller側で参照できるようにする
```html
<input type="time" id="time-input" data-dashboard-target="timeInput" class="block w-32 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
```

`data-action="click->dashboard#methodName`
イベントと呼び出すメソッドを指定する
```html
<button id="save-time" data-action="click->dashboard#saveInputTime" class="ml-2 px-4 py-1 bg-blue-300 text-white rounded-md hover:bg-blue-500">Save</button>
```

### dashboard_controller.jsにコードを追加

`static targets = [ "targetName" ]`
配列の中にtargetNameを記述する
```js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="dashboard"
export default class extends Controller {
  static targets = [ "currentTime", "timeInput", "startingTime","localTime", "timeTracked", "breakCheckbox" ]

  connect() {
```

`connect()`メソッド
コントローラーがドキュメントに接続される度、呼び出される
- 1秒ごとにupdateTime()メソッドを呼び出す
- リロード時にStartingTime、checkboxの値をローカルストレージから取得し、復元する
```js
  connect() {
    console.log("dashboard_controller connected")
    this.updateTime()
    this.interval = setInterval(() => {
      this.updateTime()
    }, 1000);

    if (this.startingTarget != "--:--"){
      const savedTime = localStorage.getItem('startingTime');
      this.startingTimeTarget.textContent = savedTime;
      this.timeInputTarget.value = savedTime;
    }

    if(this.breakCheckboxTarget.checked) {
      this.breakCheckboxTarget.checked = true;
    }
    
    const breakCheckboxState = localStorage.getItem('breakCheckboxState') === 'true';
    this.breakCheckboxTarget.checked = breakCheckboxState;
  }
```

`updateTime()`メソッド
- 現在時刻の取得
- startingTImeが入力されていたら、`timeTrackedCalculation()メソッド`を実行する
```js
  updateTime() {
    const now = new Date();
    const hours = String(now.getHours()).padStart(2, '0');
    const minutes = String(now.getMinutes()).padStart(2, '0');
    const seconds = String(now.getSeconds()).padStart(2, '0');
    const currentTime = `${hours}:${minutes}:${seconds}`;
    this.currentTimeTarget.textContent = currentTime;

    const startingTime = this.startingTimeTarget.textContent;
    if (startingTime != "--:--") {
      this.timeTrackedCalculation();
    }
  }
  ```

`saveInputTime()`メソッド
saveボタンをクリックっした時に実行されます。
- inputFieldのvalueを読み取る
- startingTimeにinputFiledの値を入れる
- ローカルストレージにstartingTimeの値を保存する
```js
  saveInputTime() {
    console.log("saveInputTime")
    const timeInput = this.timeInputTarget.value;
    this.startingTimeTarget.textContent = timeInput;
    localStorage.setItem('startingTime', timeInput);
  }
```

`timeTrackedCalculation()`メソッド
現在時刻とstartingTimeの差から現在の稼働時間を計算します。
- startingTimeの値を読み取り、形式を変換する
- チェックボックスがチェックされていたら、1時間引く
- timeTrackedに現在の稼働時間を表示する
```js
timeTrackedCalculation() {
    const startingTime = this.startingTimeTarget.textContent;
    if (startingTime) {
      const [startingHours, startingMinutes] = startingTime.split(':').map(Number);
      const startingDate = new Date();
      startingDate.setHours(startingHours, startingMinutes, 0, 0);

      const currentDate = new Date();

      let elapsedMilliseconds = currentDate - startingDate;
      if(this.breakCheckboxTarget.checked) {
        elapsedMilliseconds -= 60 * 60 * 1000;
      }

      const elapsedMinutes = Math.floor(elapsedMilliseconds / (1000 * 60));
      const elapsedHours = Math.floor(elapsedMinutes / 60);
      const remainingMinutes = elapsedMinutes % 60;
      if(elapsedHours < 0) {
        this.timeTrackedTarget.textContent = `${remainingMinutes}m`;
      } else {
        this.timeTrackedTarget.textContent = `${elapsedHours}h ${remainingMinutes}m`;
      }
    }
```

`toggleBreakCheckbox()`メソッド
チェックボックスをクリックしたときに、実行します。
リロード時にチェックボックスの状態を保持するために、ローカルストレージに保存します。

### Todoリスト
書き方のルールは変わらないので、割愛します。
稼働時間計算の処理を参考にして、リロード時にタスクが消えないように変更をしました。

<details close><summary>todo_controller.js</summary>

```js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="todo"
export default class extends Controller {
  static targets = ["inputTodo", "incompleteLists", "completeLists"]

  connect() {
    console.log("todo_controller connected")
    this.loadTodos();  
  }

  addTodo() {
    console.log("addTodo")
    const inputTodo = this.inputTodoTarget.value;
    console.log(inputTodo)
    this.createIncompleteTodo(inputTodo);
    this.inputTodoTarget.value = '';
    this.saveTodos();
  }

  createIncompleteTodo(todo) {
    const li = document.createElement('li');
    li.className = 'flex items-center justify-between';

    const p = document.createElement('p');
    p.className = 'flex-grow my-3';
    p.innerText = todo;

    const div = document.createElement('div');
    div.className = 'flex-shrink-0';

    const completeButton = document.createElement('button');
    completeButton.className = 'ml-2 px-4 py-1 bg-blue-300 text-white rounded-md hover:bg-blue-500';
    completeButton.innerText = 'Complete';
    completeButton.addEventListener('click', () => {
      const moveTarget = completeButton.closest('li');
      completeButton.nextElementSibling.remove();
      completeButton.remove();
      const backButton = document.createElement('button');
      backButton.className = 'ml-2 px-4 py-1 bg-blue-300 text-white rounded-md hover:bg-blue-500';
      backButton.innerText = 'back';
      backButton.addEventListener('click', () => {
        const todoText = backButton.closest('li').querySelector('p').innerText;
        this.createIncompleteTodo(todoText);
        this.completeListsTarget.removeChild(backButton.closest('li'));
      });
      div.appendChild(backButton);
      this.completeListsTarget.appendChild(moveTarget);
      this.saveTodos();
    });

    const deleteButton = document.createElement('button');
    deleteButton.className = 'ml-2 px-4 py-1 bg-red-300 text-white rounded-md hover:bg-red-500';
    deleteButton.innerText = 'Delete';
    deleteButton.addEventListener('click', () => {
      const deleteTarget = deleteButton.closest('li');
      this.incompleteListsTarget.removeChild(deleteTarget);
      this.saveTodos();
    });

    li.appendChild(p);
    li.appendChild(div);
    div.appendChild(completeButton);
    div.appendChild(deleteButton);
    this.incompleteListsTarget.appendChild(li);
  }

  saveTodos() {
    const incompleteTodos = Array.from(this.incompleteListsTarget.children).map(li => li.querySelector('p').innerText);
    const completeTodos = Array.from(this.completeListsTarget.children).map(li => li.querySelector('p').innerText);
    localStorage.setItem('incompleteTodos', JSON.stringify(incompleteTodos));
    localStorage.setItem('completeTodos', JSON.stringify(completeTodos));
  }

  loadTodos() {
    const incompleteTodos = JSON.parse(localStorage.getItem('incompleteTodos')) || [];
    const completeTodos = JSON.parse(localStorage.getItem('completeTodos')) || [];

    incompleteTodos.forEach(todo => this.createIncompleteTodo(todo));
    completeTodos.forEach(todo => {
      const li = document.createElement('li');
      li.className = 'flex items-center justify-between';

      const p = document.createElement('p');
      p.className = 'flex-grow my-3';
      p.innerText = todo;

      const div = document.createElement('div');
      div.className = 'flex-shrink-0';

      const backButton = document.createElement('button');
      backButton.className = 'ml-2 px-4 py-1 bg-blue-300 text-white rounded-md hover:bg-blue-500';
      backButton.innerText = 'back';
      backButton.addEventListener('click', () => {
        const todoText = backButton.closest('li').querySelector('p').innerText;
        this.createIncompleteTodo(todoText);
        this.completeListsTarget.removeChild(backButton.closest('li'));
        this.saveTodos();
      });

      li.appendChild(p);
      li.appendChild(div);
      div.appendChild(backButton);
      this.completeListsTarget.appendChild(li);
    });
  }
}

document.getElementById('add-button').addEventListener('click', (event) => {
  const controller = event.currentTarget.closest('[data-controller="todo"]').controller;
  controller.addTodo();
});
```
</details>

# まとめ
今回はstimulusの使ってダッシュボードの実装を行いました。

最初は、stimulusがJavascriptのフレームワークという認識が浅く、書き方などを意識しないまま実装しようとして、苦戦してしまいました。
慣れると、書き方はシンプルでとても使いやすかったです。

今後もダッシュボードに機能を追加しながら、色々と学んでいきたいと思います。