【ClickUp】ClickUp APIを使って稼働時間とタスク一覧を取得してみた

# はじめに
自分用の使いやすいダッシュボードの制作を進めています。

https://qiita.com/shoya_u/items/2fd332c2ad681d7af807

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

今回は担当プロジェクトの稼働ペース予測処理を簡易的に実装していきます。

スプレッドシートでも作成は可能です。
日付は今日と前日の2パターン対応できる形にしています。
![Cursor_と_勤怠時間計算_-_Google_スプレッドシート.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/c7abe6e3-1b3e-4d48-9f45-24985042325c.png)
| 項目 | 数式 |
| --- | --- |
| 今日で計測 or 昨日で計測 | =IF(F6="今日で計測", TODAY(), TODAY()-1) |
| 営業日数 | =NETWORKDAYS(G4,G6,E14:E32) |
| 残営業日数 | =NETWORKDAYS(G4,H4,E14:E32)-G7 |
| 現稼働時間 | =H9+I9 |
| 増加値 | =(G9/G7)*G8 |
| 予測合計値 | =G9+G10 |

[**土日祝を除いた平日**]を計算する数式の存在を初めて知りました。
```
= NETWORKDAYS(開始日,終了日,祭日)
```


上記の実装を、ClickUpと連携して自動出力できるようにしたいので、Railsで試していきます。

# 完成図
![Science_App.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/d8f8e95a-b900-4975-8d24-88b81088a179.png)

# Controllerの作成
まずは、手動で現在の累計稼働時間を入力する形で実装します。

現在の累計稼働時間から増加値予測と合計値予測を算出します。

祝日判定にはgemの`holidays`を使用します。

```
$ bundle add holidays
```

```ruby
require "holidays"

class AnalyticsController < ApplicationController
  def index
    @users = User.all

    @today = Date.current

    calculate_until = params[:calculate_until] || "today"
    target_date = calculate_until == "yesterday" ? @today.yesterday : @today
    @today_formatted = target_date.strftime("%-m月%d日 (%a)")

    @hours_u1 = params[:total_hours_user1].to_f || 0.0
    @hours_u2 = params[:total_hours_user2].to_f || 0.0

    start_date = target_date.beginning_of_month
    end_date = target_date.end_of_month

    @business_days = (start_date..target_date).count { |date| business_day?(date) }
    @remaining_days = (target_date.next_day..end_date).count { |date| business_day?(date) }

    @per_day_u1 = @business_days > 0 ? (@hours_u1 / @business_days) : 0
    @predicted_u1 = @per_day_u1 * @remaining_days
    @final_u1 = @hours_u1 + @predicted_u1

    @per_day_u2 = @business_days > 0 ? (@hours_u2 / @business_days) : 0
    @predicted_u2 = @per_day_u2 * @remaining_days
    @final_u2 = @hours_u2 + @predicted_u2

    @total_final = @final_u1 + @final_u2
  end

  private
  def business_day?(date)
    !date.saturday? && !date.sunday? && Holidays.on(date, :jp).empty?
  end
end
```

</details>

# Viewの作成
手動で入力した値を使って、稼働時間予測値を表示します。
- 対象ユーザーは2名
- 残り営業日数から増加予測値を算出
- 現在の稼働時間と合算した値をペース予測として出力する

```html
<div class="mt-10 max-w-lg mx-auto">
  <h2 class="text-xl font-bold text-center mb-4">稼働時間計算</h2>
  <div class="bg-white p-6 rounded-lg shadow-lg">
    <form method="get" action="<%= analytics_path %>">
      <div class="mb-4">
        <label for="total_hours_user1" class="block text-gray-700 font-bold mb-2">User1の累計稼働時間（h）</label>
        <input type="number" step="0.01" name="total_hours_user1" id="total_hours_user1" value="<%= @hours_u1 %>" class="w-full p-2 border rounded">
      </div>
      <div class="mb-4">
        <label for="total_hours_user2" class="block text-gray-700 font-bold mb-2">User2の累計稼働時間（h）</label>
        <input type="number" step="0.01" name="total_hours_user2" id="total_hours_user2" value="<%= @hours_u2 %>" class="w-full p-2 border rounded">
      </div>
      <button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded">計算</button>
      <button type="submit" name="calculate_until" value="yesterday" class="bg-gray-500 text-white px-4 py-2 rounded">昨日の日付で計算</button>
    </form>
    <table class="table-auto w-full bg-white rounded-lg shadow-lg mt-4">
      <tbody>
        <tr class="border-t">
          <td class="px-4 py-2">日付</td>
          <td class="px-4 py-2"><%= @today_formatted %></td>
        </tr>
        <tr class="border-t">
          <td class="px-4 py-2">営業日数</td>
          <td class="px-4 py-2"><%= @business_days %> 日</td>
        </tr>
        <tr class="border-t">
          <td class="px-4 py-2">残り営業日数</td>
          <td class="px-4 py-2"><%= @remaining_days %> 日</td>
        </tr>
      </tbody>
    </table>
    <p class="text-lg mt-4">累計稼働時間： <span class="font-bold"><%= (@hours_u1 + @hours_u2).round(1) %> h</span><span class="text-sm"> (現在)</p>
    <table class="table-auto w-full bg-white rounded-lg shadow-lg mt-4">
      <thead>
        <tr class="bg-gray-300">
          <th class="px-4 py-2">項目</th>
          <th class="px-4 py-2">User1</th>
          <th class="px-4 py-2">User2</th>
          <th class="px-4 py-2">合計</th>
        </tr>
      </thead>
      <tbody class="text-center">
        <tr class="border-t">
          <td class="px-4 py-2 text-left">
            <div class="flex items-center">
              <span>稼働時間</span>
              <span class="ml-2 text-sm text-gray-500">(h/日)</span>
            </div>
          </td>
          <td class="px-4 py-2"><%= @per_day_u1.round(1) %></td>
          <td class="px-4 py-2"><%= @per_day_u2.round(1) %></td>
          <td class="px-4 py-2 font-bold"><%= (@per_day_u1 + @per_day_u2).round(1) %></td>
        </tr>
        <tr class="border-t">
          <td class="px-4 py-2 text-left">
            <div class="flex items-center">
              <span>増加予測</span>
              <span class="ml-2 text-sm text-gray-500">(h/月)</span>
            </div>
          </td>
          <td class="px-4 py-2"><%= @predicted_u1.round(1) %></td>
          <td class="px-4 py-2"><%= @predicted_u2.round(1) %></td>
          <td class="px-4 py-2 font-bold"><%= (@predicted_u1 + @predicted_u2).round(1) %></td>
        </tr>
        <tr class="border-t-3 border-double">
          <td class="px-4 py-2 text-left">
            <div class="flex items-center">
              <span>累計予測</span>
              <span class="ml-2 text-sm text-gray-500">(h/月)</span>
            </div>
          </td>
          <td class="px-4 py-2"><%= @final_u1.round(1) %></td>
          <td class="px-4 py-2"><%= @final_u2.round(1) %></td>
          <td class="px-4 py-2 font-bold"><%= (@final_u1 + @final_u2).round(1) %></td>
        </tr>
      </tbody>
    </table>
    <p class="text-lg mt-4">累計稼働時間： <span class="font-bold"><%= (@final_u1 + @final_u2).round(1) %> h</span><span class="text-sm"> (予測)</p>
  </div>
</div>
```

</details>

# ClickUp API Tokenの取得
現在の累計稼働時間の入力欄に、ClickUpから取得した値を初期値として表示させます。
**自分以外のユーザー情報は管理者権限が必要**だったため、自分の稼働時間のみ取得します。

https://developer.clickup.com/


:::note info
ClickUpAPIのRate Limitsはワークスペースのプランによって異なります。
- 永久無料、無制限、ビジネス: トークンあたり 1 分あたり 100 リクエスト
- Business Plus : トークンあたり 1 分あたり 1,000 件のリクエスト
- エンタープライズ: トークンあたり 1 分あたり 10,000 件のリクエスト
:::


ClickUpの右上のプロフィールメニューから[Setting]を選択します。

[Apps]から[Generate]を選択すると、API Tokenが生成できます。
![Cursor_と_App_Settings___HAB_Co_.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/4f53f95d-1f43-4bfb-ba28-c3456764058a.png)

# 稼働時間の取得
ClickUp API Referenceの[**Time Tracking**]セクションの[**Get time entries within a date range**]を参考に作成します。
![Get_time_entries_within_a_date_range.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/94501aa8-17b8-4141-94ef-96e8c4051d5b.png)

モデルを作成してClickupAPIで稼働時間を取得します。

データベースの構造変更はないので、migrationファイルは作成せずに、modelを作成します。
```
$ rails g model clickup --skip-migration
```

```ruby
require "uri"
require "net/http"
require "json"

class Clickup < ApplicationRecord
  def self.base_url
    ENV["CLICKUP_API_BASE_URL"]
  end

  def self.list_id
    ENV["CLICKUP_LIST_ID"]
  end

  def self.api_key
    ENV["CLICKUP_API_KEY"]
  end

  def self.perform_request(url)
    http = Net::HTTP.new(url.host, url.port)
    http.use_ssl = true

    request = Net::HTTP::Get.new(url)
    request["accept"] = "application/json"
    request["Authorization"] = api_key

    begin
      response = http.request(request)
      if response.code == "200"
        JSON.parse(response.body)
      else
        Rails.logger.error("ClickUp API Error: #{response.code} - #{response.body}")
        nil
      end
    rescue StandardError => e
      Rails.logger.error("Failed to perform request to ClickUp: #{e.message}")
      nil
    end
  end

  def self.fetch_tracked_time
    team_id = ENV["CLICKUP_TEAM_ID"]
    assignee_id = ENV["CLICKUP_ASSIGNEE_ID"]
    start_date = (Date.today.beginning_of_month.to_time.to_i * 1000).to_s
    end_date = (Date.today.end_of_month.to_time.to_i * 1000).to_s

    url = URI("#{base_url}/team/#{team_id}/time_entries?assignee=#{assignee_id}&list_id=#{list_id}&start_date=#{start_date}&end_date=#{end_date}")
    response = perform_request(url)

    if response
      time_entries = response["data"]
      filtered_entries = time_entries.reject do |entry|
        entry.dig("task", "status", "status") == "未着手"
      end

      total_duration_ms = filtered_entries.sum { |entry| entry["duration"].to_i }
      (total_duration_ms / (1000 * 60 * 60.0)).round(2)
    else
      0.0
    end
  end
end

```

</details>

API_KEY, API_BASE_URL, LIST_ID, TEAM_ID, ASSIGNEE_IDは`.env`ファイルに記載します。
```ruby
CLICKUP_API_KEY='ab_1231234_ABCXXXXXXXXXXXXXXX'
CLICKUP_API_BASE_URL='https://api.clickup.com/api/v2'
CLICKUP_LIST_ID='12345678'
CLICKUP_TEAM_ID='11112222'
CLICKUP_ASSIGNEE_ID='1212121'
```

稼働時間を取得する期間の指定ができます。
```ruby
url = URI("#{base_url}/team/#{team_id}/time_entries?assignee=#{assignee_id}&list_id=#{list_id}&start_date=#{start_date}&end_date=#{end_date}")
```
start_dateとend_dateはUNIX時間で表記する必要があるので、変換します。
![Cursor_と_Get_time_entries_within_a_date_range.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/d7814079-fae5-4c68-a13c-3105ef76ad36.png)

`begining_of_month`と`end_of_month`で現在の月の月初と月末の日付を取得します。
（今日が2025年3月25の場合、3月1日と3月31日をそれぞれ取得する）
```ruby
start_date = (Date.today.beginning_of_month.to_time.to_i * 1000).to_s
end_date = (Date.today.end_of_month.to_time.to_i * 1000).to_s
```

statusが"未着手"のタスクは、稼働時間に含めないので除外します。
```ruby
time_entries = response["data"]

filtered_entries = time_entries.reject do |entry|
  entry.dig("task", "status", "status") == "未着手"
end
```


# Controllerの修正
コントローラーでインスタンス変数を定義します。
```ruby
@total_duration_hours = Clickup.fetch_tracked_time
```

初期値にClickUpから取得した値を入れるために`@hours_u1`を修正します。
手動で値を修正した場合は、その値が反映されます。
```ruby
@hours_u1 = params[:total_hours_user1].present? ? params[:total_hours_user1].to_f : Clickup.fetch_tracked_time
```


# Viewの修正
User1の累計稼働時間入力欄に、ClickUpから取得した値を初期値として表示させるために修正します。
```html
<div class="mb-4">
  <label for="total_hours_user1" class="block text-gray-700 font-bold mb-2">User1の累計稼働時間（h）</label>
  <input type="number" step="0.01" name="total_hours_user1" id="total_hours_user1" value="<%= params[:total_hours_user1] || @hours_u1 %>" class="w-full p-2 border rounded">
</div>
```

# タスク一覧の取得
せっかくなので、指定のリストからタスク一覧の取得も試しました。

ClickUp API Referenceの[**Tasks**]セクションの[**Get Tasks**]を参考に作成します。
![Cursor_と_Get_Tasks.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/1ae90776-dc8d-465a-8f13-609e2aef132b.png)

clickup.rbにタスク一覧を取得するメソッドを追加します。
```ruby
def self.fetch_tasks
  url = URI("#{base_url}/list/#{list_id}/task")
  response = perform_request(url)
  response ? response["tasks"] : []
end
```

# Controllerの修正
コントローラーでインスタンス変数を定義します。
```ruby
class AnalyticsController < ApplicationController
  def index
    @tasks = Clickup.fetch_tasks
  end
end
```

# Viewの修正
取得したタスク情報から、Title, Assignees, Status, Created_atを表示します。
```html
<div class="mt-10 p-8">
  <h2 class="text-xl font-bold text-center mb-4">タスク一覧</h2>
  <table class="table-auto w-full bg-white rounded-lg shadow-lg">
    <thead>
      <tr class="bg-gray-300">
        <th class="px-4 py-2">Name</th>
        <th class="px-4 py-2">Assignees</th>
        <th class="px-4 py-2">Status</th>
        <th class="px-4 py-2">Created_at</th>
      </tr>
    </thead>
    <tbody>
      <% if @tasks.present? %>
      <% @tasks.each do |task| %>
      <tr class="border-t">
        <td class="px-4 py-2"><%= task["name"] %></td>
        <td class="px-4 py-2">
          <% if task["assignees"].present? %>
          <% task["assignees"].each do |assignee| %>
          <div class="flex items-center space-x-2 mb-1">
            <img src="<%= assignee["profilePicture"] %>" alt="<%= assignee["username"] %>" class="w-8 h-8 rounded-full">
            <span><%= assignee["username"] %></span>
          </div>
          <% end %>
          <% else %>
          未設定
          <% end %>
        </td>
        <td class="px-4 py-2"><%= task["status"]["status"] %></td>
        <td class="px-4 py-2">
          <% if task["date_created"].present? %>
          <%= Time.at(task["date_created"].to_i / 1000).strftime('%Y-%m-%d') %>
          <% else %>
          --
          <% end %>
        </td>
      </tr>
      <% end %>
      <% else %>
      <tr>
        <td colspan="3" class="px-4 py-2 text-center">タスクが見つかりません。</td>
      </tr>
      <% end %>
    </tbody>
  </table>
</div>
```

タスク一覧が表示できました。
AssigneesはClickUpで設定しているプロフィール画像の取得もできました。
![Science_App.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/7b4d0d69-3846-4a96-8175-7af3184bfe00.png)

# まとめ
今回はClickUp APIを利用して、タスクや稼働時間を取得することができました。

API連携は初挑戦だったので、実装まで辿り着けて良かったです。
ClickUp APIの公式Referenceが、充実していたので助かりました。

今後はSalesforceと外部アプリケーションのAPI連携も試していきたいと思います。