はじめに
自分用の使いやすいダッシュボードの制作を進めています。
今回は担当プロジェクトの稼働ペース予測処理を簡易的に実装していきます。
スプレッドシートでも作成は可能です。
日付は今日と前日の2パターン対応できる形にしています。
項目 | 数式 |
---|---|
今日で計測 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で試していきます。
完成図
Controllerの作成
まずは、手動で現在の累計稼働時間を入力する形で実装します。
現在の累計稼働時間から増加値予測と合計値予測を算出します。
祝日判定にはgemのholidays
を使用します。
$ bundle add holidays
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
Viewの作成
手動で入力した値を使って、稼働時間予測値を表示します。
- 対象ユーザーは2名
- 残り営業日数から増加予測値を算出
- 現在の稼働時間と合算した値をペース予測として出力する
<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>
ClickUp API Tokenの取得
現在の累計稼働時間の入力欄に、ClickUpから取得した値を初期値として表示させます。
自分以外のユーザー情報は管理者権限が必要だったため、自分の稼働時間のみ取得します。
ClickUpAPIのRate Limitsはワークスペースのプランによって異なります。
- 永久無料、無制限、ビジネス: トークンあたり 1 分あたり 100 リクエスト
- Business Plus : トークンあたり 1 分あたり 1,000 件のリクエスト
- エンタープライズ: トークンあたり 1 分あたり 10,000 件のリクエスト
ClickUpの右上のプロフィールメニューから[Setting]を選択します。
[Apps]から[Generate]を選択すると、API Tokenが生成できます。
稼働時間の取得
ClickUp API Referenceの[Time Tracking]セクションの[Get time entries within a date range]を参考に作成します。
モデルを作成してClickupAPIで稼働時間を取得します。
データベースの構造変更はないので、migrationファイルは作成せずに、modelを作成します。
$ rails g model clickup --skip-migration
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
API_KEY, API_BASE_URL, LIST_ID, TEAM_ID, ASSIGNEE_IDは.env
ファイルに記載します。
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'
稼働時間を取得する期間の指定ができます。
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時間で表記する必要があるので、変換します。
begining_of_month
とend_of_month
で現在の月の月初と月末の日付を取得します。
(今日が2025年3月25の場合、3月1日と3月31日をそれぞれ取得する)
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が"未着手"のタスクは、稼働時間に含めないので除外します。
time_entries = response["data"]
filtered_entries = time_entries.reject do |entry|
entry.dig("task", "status", "status") == "未着手"
end
Controllerの修正
コントローラーでインスタンス変数を定義します。
@total_duration_hours = Clickup.fetch_tracked_time
初期値にClickUpから取得した値を入れるために@hours_u1
を修正します。
手動で値を修正した場合は、その値が反映されます。
@hours_u1 = params[:total_hours_user1].present? ? params[:total_hours_user1].to_f : Clickup.fetch_tracked_time
Viewの修正
User1の累計稼働時間入力欄に、ClickUpから取得した値を初期値として表示させるために修正します。
<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]を参考に作成します。
clickup.rbにタスク一覧を取得するメソッドを追加します。
def self.fetch_tasks
url = URI("#{base_url}/list/#{list_id}/task")
response = perform_request(url)
response ? response["tasks"] : []
end
Controllerの修正
コントローラーでインスタンス変数を定義します。
class AnalyticsController < ApplicationController
def index
@tasks = Clickup.fetch_tasks
end
end
Viewの修正
取得したタスク情報から、Title, Assignees, Status, Created_atを表示します。
<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で設定しているプロフィール画像の取得もできました。
まとめ
今回はClickUp APIを利用して、タスクや稼働時間を取得することができました。
API連携は初挑戦だったので、実装まで辿り着けて良かったです。
ClickUp APIの公式Referenceが、充実していたので助かりました。
今後はSalesforceと外部アプリケーションのAPI連携も試していきたいと思います。