lib/wakatime_service.rb
Rama main · réplica de lectura del repositorio original.
require "digest"
include ApplicationHelper
include ErrorReporting
class WakatimeService
def initialize(user: nil, specific_filters: [], allow_cache: true, limit: 10, start_date: nil, end_date: nil, scope: nil, boundary_aware: false, valid_timestamps_only: false, exclude_categories: [])
@scope = scope || Heartbeat.all
@scope = @scope.with_valid_timestamps if valid_timestamps_only
@scope = @scope.where.not("LOWER(category) IN (?)", exclude_categories) if exclude_categories.any?
@exclude_categories = exclude_categories
@user = user
@boundary_aware = boundary_aware
@start_date = convert_to_unix_timestamp(start_date)
@end_date = convert_to_unix_timestamp(end_date)
# Default to 1 year ago if no start_date provided or if no data exists
@start_date = @start_date || @scope.minimum(:time) || 1.year.ago.to_i
@end_date = @end_date || @scope.maximum(:time) || Time.current.to_i
@scope = @scope.where("time >= ? AND time < ?", @start_date, @end_date)
@limit = limit
@limit = nil if @limit&.zero?
@scope = @scope.where(user_id: @user.id) if @user.present?
@specific_filters = specific_filters
@allow_cache = allow_cache
@raw_names = boundary_aware # test-mode parity: use raw key.presence names when boundary_aware
end
def generate_summary
return cached_summary if @allow_cache
build_summary
end
def generate_daily_summaries(timezone:, start_date:, end_date:)
activity_by_date = daily_activity(timezone)
data = (start_date..end_date).map do |date|
build_daily_summary(date, activity_by_date[date], timezone)
end
total_seconds = data.sum { |summary| summary.dig(:grand_total, :total_seconds) }
active_days = data.count { |summary| summary.dig(:grand_total, :total_seconds).positive? }
average_seconds = data.any? ? total_seconds / data.length : 0
{
data: data,
cumulative_total: cumulative_duration(total_seconds),
daily_average: {
holidays: data.length - active_days,
days_including_holidays: data.length,
days_minus_holidays: active_days,
seconds: average_seconds,
seconds_including_other_language: average_seconds,
text: duration_text(average_seconds),
text_including_other_language: duration_text(average_seconds)
},
start: start_date.iso8601,
end: end_date.iso8601
}
end
def cached_summary
Rails.cache.fetch(summary_cache_key, expires_in: 1.minute) do
build_summary
end
end
def build_summary
summary = {}
summary[:username] = @user.display_name if @user.present?
summary[:user_id] = @user.id.to_s if @user.present?
summary[:is_coding_activity_visible] = true if @user.present?
summary[:is_other_usage_visible] = true if @user.present?
summary[:status] = "ok"
@start_time = @start_date
@end_time = @end_date
summary[:start] = Time.at(@start_time).strftime("%Y-%m-%dT%H:%M:%SZ")
summary[:end] = Time.at(@end_time).strftime("%Y-%m-%dT%H:%M:%SZ")
summary[:range] = "all_time"
summary[:human_readable_range] = "All Time"
# The grouped durations run on the async query executor while the total is computed.
chunks = { languages: :language, projects: :project }.select { |filter, _| @specific_filters.include?(filter) }
.to_h { |filter, group_by| [ filter, [ group_by, @scope.group(group_by).duration_seconds(async: true) ] ] }
@total_seconds = if @boundary_aware
Heartbeat.duration_seconds_boundary_aware(@scope, @start_date, @end_date, excluded_categories: @exclude_categories) || 0
else
@scope.duration_seconds || 0
end
summary[:total_seconds] = @total_seconds
@total_days = (@end_time - @start_time) / 86400
summary[:daily_average] = @total_days.zero? ? 0 : @total_seconds / @total_days
summary[:human_readable_total] = ApplicationController.helpers.short_time_detailed(@total_seconds)
summary[:human_readable_daily_average] = ApplicationController.helpers.short_time_detailed(summary[:daily_average])
chunks.each { |filter, (group_by, durations)| summary[filter] = generate_summary_chunk(group_by, durations.value) }
summary
end
def summary_cache_key
scope_digest = Digest::SHA256.hexdigest(@scope.to_sql)
filters = @specific_filters.map(&:to_s).sort.join(",")
[ "wakatime_service", "summary", "v1", scope_digest, filters, @limit, @user&.heartbeat_cache_version ].join(":")
end
def generate_summary_chunk(group_by, durations)
result = []
durations.each do |key, value|
entry = {
name: @raw_names ? (key.presence || "Other") : transform_display_name(group_by, key),
total_seconds: value,
text: ApplicationController.helpers.short_time_simple(value),
hours: value / 3600,
minutes: (value % 3600) / 60,
percent: (100.0 * value / @total_seconds).round(2),
digital: ApplicationController.helpers.digital_time(value)
}
entry[:color] = LanguageUtils.color(key) if group_by == :language
result << entry
end
result = result.sort_by { |item| -item[:total_seconds] }
result = result.first(@limit) if @limit.present?
result
end
def transform_display_name(group_by, key)
value = key.presence || "Other"
case group_by
when :editor
ApplicationController.helpers.display_editor_name(value)
when :operating_system
ApplicationController.helpers.display_os_name(value)
when :language
ApplicationController.helpers.display_language_name(value)
else
value
end
end
def self.categorize_language(language)
return nil if language.blank?
LanguageUtils.display_name(language)
end
private
def daily_activity(timezone)
activity_by_date = Hash.new do |hash, date|
hash[date] = {
total_seconds: 0,
projects: Hash.new(0),
ai_input_tokens: 0,
ai_input_token_count: 0,
ai_output_tokens: 0,
ai_output_token_count: 0,
ai_models: Hash.new(0)
}
end
Heartbeat.daily_activity_summary_rows(scope: @scope, timezone: timezone).each do |row|
activity = activity_by_date[row.fetch("local_date").to_date]
duration = row.fetch("duration").to_i
activity[:total_seconds] += duration
activity[:projects][row["project"].presence || "Other"] += duration
activity[:ai_input_tokens] += row.fetch("ai_input_tokens").to_i
activity[:ai_input_token_count] += row.fetch("ai_input_token_count").to_i
activity[:ai_output_tokens] += row.fetch("ai_output_tokens").to_i
activity[:ai_output_token_count] += row.fetch("ai_output_token_count").to_i
activity[:ai_models][row["ai_model"]] += row.fetch("ai_line_changes").to_i if row["ai_model"].present?
end
activity_by_date
end
def build_daily_summary(date, activity, timezone)
activity ||= {
total_seconds: 0,
projects: {},
ai_input_tokens: 0,
ai_input_token_count: 0,
ai_output_tokens: 0,
ai_output_token_count: 0,
ai_models: {}
}
total_seconds = activity[:total_seconds]
grand_total = duration(total_seconds)
grand_total[:ai_input_tokens] = activity[:ai_input_tokens] if activity[:ai_input_token_count].positive?
grand_total[:ai_output_tokens] = activity[:ai_output_tokens] if activity[:ai_output_token_count].positive?
if activity[:ai_models].any?
grand_total[:ai_model_breakdown] = activity[:ai_models]
.sort_by { |name, lines| [ -lines, name ] }
.map { |name, lines| { name: name, lines: lines } }
end
day_start = date.beginning_of_day
{
grand_total: grand_total,
branches: [],
categories: [],
dependencies: [],
editors: [],
entities: [],
languages: [],
machines: [],
operating_systems: [],
projects: project_breakdown(activity[:projects], total_seconds),
range: {
date: date.iso8601,
start: day_start.utc.iso8601,
end: day_start.end_of_day.utc.iso8601,
text: summary_date_text(date),
timezone: timezone
}
}
end
def project_breakdown(projects, total_seconds)
projects.filter_map do |name, seconds|
next unless seconds.positive?
duration(seconds).merge(
name: name,
percent: total_seconds.positive? ? ((seconds.to_f / total_seconds) * 100).round(2) : 0
)
end.sort_by { |project| [ -project[:total_seconds], project[:name] ] }
end
def duration(total_seconds)
total_seconds = total_seconds.to_i
hours, remainder = total_seconds.divmod(3600)
minutes, seconds = remainder.divmod(60)
{
total_seconds: total_seconds,
hours: hours,
minutes: minutes,
seconds: seconds,
digital: ApplicationController.helpers.digital_time(total_seconds),
decimal: format("%.2f", total_seconds / 3600.0),
text: duration_text(total_seconds)
}
end
def cumulative_duration(total_seconds)
value = duration(total_seconds)
value[:seconds] = value.delete(:total_seconds)
value
end
def duration_text(total_seconds)
ApplicationController.helpers.short_time_detailed(total_seconds).presence || "0s"
end
def summary_date_text(date)
today = Date.current
return "Today" if date == today
return "Yesterday" if date == today - 1.day
date.to_fs(:long)
end
def convert_to_unix_timestamp(timestamp)
# our lord and savior stack overflow for this bit of code
return nil if timestamp.nil?
case timestamp
when String
Time.parse(timestamp).to_i
when Time, DateTime, Date
timestamp.to_i
when Numeric
timestamp.to_i
else
nil
end
rescue ArgumentError => e
report_error(e, message: "Error converting timestamp")
nil
end
end