Ctrl K
ComunidadGuías…

lib/wakatime_service.rb

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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