hackatime/app/controllers/static_pages_controller.rb
Echo 4466d8d820
leaderboards rework (#623)
* rolling leaderboards

* use actual deletions over soft deletes
2025-11-15 17:34:13 -05:00

405 lines
15 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class StaticPagesController < ApplicationController
before_action :ensure_current_user, only: %i[
filterable_dashboard
filterable_dashboard_content
]
def index
if current_user
flavor_texts = FlavorText.motto + FlavorText.conditional_mottos(current_user)
flavor_texts += FlavorText.rare_motto if Random.rand(10) < 1
@flavor_text = flavor_texts.sample
unless params[:date].blank?
# implement this later for now just redirect to the projects page with the date
begin
date = Date.parse(params[:date])
redirect_to "/my/projects?interval=custom&from=#{date}&to=#{date}"
rescue ArgumentError
end
end
if current_user.heartbeats.empty? || params[:show_wakatime_setup_notice]
@show_wakatime_setup_notice = true
setup_social_proof = Cache::SetupSocialProofJob.perform_now
if setup_social_proof.present?
@ssp_message = setup_social_proof[:message]
@ssp_users_recent = setup_social_proof[:users_recent]
@ssp_users_size = setup_social_proof[:users_size]
end
end
# Get languages and editors in a single query using window functions
Time.use_zone(current_user.timezone) do
results = current_user.heartbeats.today
.select(
:language,
:editor,
"COUNT(*) OVER (PARTITION BY language) as language_count",
"COUNT(*) OVER (PARTITION BY editor) as editor_count"
)
.distinct
.to_a
# Process results to get sorted languages and editors
language_counts = results
.map { |r| [ r.language&.downcase, r.language_count ] }
.reject { |lang, _| lang.nil? || lang.empty? }
.uniq
.sort_by { |_, count| -count }
editor_counts = results
.map { |r| [ r.editor, r.editor_count ] }
.reject { |ed, _| ed.nil? || ed.empty? }
.uniq
.sort_by { |_, count| -count }
@todays_languages = language_counts.map(&:first).map { |language| ApplicationController.helpers.display_language_name(language) }
@todays_editors = editor_counts.map(&:first).map { |editor| ApplicationController.helpers.display_editor_name(editor) }
@todays_duration = current_user.heartbeats.today.duration_seconds
if @todays_duration > 1.minute
@show_logged_time_sentence = @todays_languages.any? || @todays_editors.any?
end
end
cached_data = filterable_dashboard_data
cached_data.entries.each do |key, value|
instance_variable_set("@#{key}", value)
end
else
# Set homepage SEO content for logged-out users only
set_homepage_seo_content
@usage_social_proof = Cache::UsageSocialProofJob.perform_now
@home_stats = Cache::HomeStatsJob.perform_now
end
end
def minimal_login
@continue_param = params[:continue] if params[:continue].present?
render :minimal_login, layout: "doorkeeper/application"
end
def what_is_hackatime
@page_title = "What is Hackatime? - Free Coding Time Tracker"
@meta_description = "Hackatime is a free, open-source coding time tracker built by Hack Club for high school students. Track your programming time across 75+ editors and see your coding statistics."
@meta_keywords = "what is hackatime, hackatime definition, hack club time tracker, coding time tracker, programming statistics"
@og_title = @page_title
@og_description = @meta_description
@twitter_title = @page_title
@twitter_description = @meta_description
end
def mini_leaderboard
@leaderboard = LeaderboardService.get(
period: :daily,
date: Date.current
)
@active_projects = Cache::ActiveProjectsJob.perform_now
render partial: "leaderboards/mini_leaderboard", locals: {
leaderboard: @leaderboard,
current_user: current_user
}
end
def project_durations
return unless current_user
@project_repo_mappings = current_user.project_repo_mappings.includes(:repository)
cache_key = "user_#{current_user.id}_project_durations_#{params[:interval]}"
cache_key += "_#{params[:from]}_#{params[:to]}" if params[:interval] == "custom"
project_durations = Rails.cache.fetch(cache_key, expires_in: 1.minute) do
heartbeats = current_user.heartbeats.filter_by_time_range(params[:interval], params[:from], params[:to])
project_times = heartbeats.group(:project).duration_seconds
project_labels = current_user.project_labels
project_times.map do |project, duration|
mapping = @project_repo_mappings.find { |p| p.project_name == project }
{
project: project_labels.find { |p| p.project_key == project }&.label || project || "Unknown",
repo_url: mapping&.repo_url,
repository: mapping&.repository,
duration: duration
}
end.filter { |p| p[:duration].positive? }.sort_by { |p| p[:duration] }.reverse
end
render partial: "project_durations", locals: { project_durations: project_durations }
end
def activity_graph
return unless current_user
user_tz = current_user.timezone
cache_key = "user_#{current_user.id}_daily_durations_#{user_tz}"
daily_durations = Rails.cache.fetch(cache_key, expires_in: 1.minute) do
Time.use_zone(user_tz) do
current_user.heartbeats.daily_durations(user_timezone: user_tz).to_h
end
end
# Consider 8 hours as a "full" day of coding
length_of_busiest_day = 8.hours.to_i # 28800 seconds
render partial: "activity_graph", locals: {
daily_durations: daily_durations,
length_of_busiest_day: length_of_busiest_day
}
end
def currently_hacking
locals = Cache::CurrentlyHackingJob.perform_now
respond_to do |format|
format.html { render partial: "currently_hacking", locals: locals }
format.json do
json_response = locals[:users].map do |user|
{
id: user.id,
username: user.display_name,
slack_username: user.slack_username,
github_username: user.github_username,
display_name: user.display_name,
avatar_url: user.avatar_url,
slack_uid: user.slack_uid,
active_project: locals[:active_projects][user.id]&.then do |project|
{
name: project.project_name,
repo_url: project.repo_url
}
end
}
end
render json: {
count: locals[:users].count,
users: json_response
}
end
end
end
def currently_hacking_count
result = Cache::CurrentlyHackingCountJob.perform_now
respond_to do |format|
format.json { render json: { count: result[:count] } }
end
end
def streak
render partial: "streak"
end
def filterable_dashboard
cached_data = filterable_dashboard_data
cached_data.entries.each do |key, value|
instance_variable_set("@#{key}", value)
end
render partial: "filterable_dashboard"
end
def filterable_dashboard_content
cached_data = filterable_dashboard_data
cached_data.entries.each do |key, value|
instance_variable_set("@#{key}", value)
end
render partial: "filterable_dashboard_content"
end
private
def ensure_current_user
redirect_to root_path, alert: "You must be logged in to view this page" unless current_user
end
def set_homepage_seo_content
title = "Hackatime - See How Much You Code"
desc = "Free and open source. Works with VS Code, JetBrains IDEs, vim, emacs, and 70+ other editors. Built and made free for teenagers by Hack Club."
@page_title = title
@meta_description = desc
@meta_keywords = "coding time tracker, programming stats, open source time tracker, hack club coding tracker, free time tracking, code statistics, high school programming, coding analytics"
@og_title = title
@og_description = desc
@twitter_title = title
@twitter_description = desc
end
def filterable_dashboard_data
filters = %i[project language operating_system editor category]
# Cache key based on user and filter parameters
cache_key = []
cache_key << current_user
filters.each do |filter|
cache_key << params[filter]
end
def dashboard
@project = Project.distinct.pluck(:name)
@language = Language.distinct.pluck(:name)
@operating_system = OperatingSystem.distinct.pluck(:name)
@editor = Editor.distinct.pluck(:name)
@category = Category.distinct.pluck(:name)
# Parse filter selections from params for initial load and deep-linking
@selected_project = params[:project]&.split(",") || []
@selected_language = params[:language]&.split(",") || []
@selected_operating_system = params[:operating_system]&.split(",") || []
@selected_editor = params[:editor]&.split(",") || []
@selected_category = params[:category]&.split(",") || []
end
filtered_heartbeats = current_user.heartbeats
# Load filter options and apply filters with caching
Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
result = {}
# Load filter options
Time.use_zone(current_user.timezone) do
filters.each do |filter|
group_by_time = current_user.heartbeats.group(filter).duration_seconds
result[filter] = group_by_time.sort_by { |k, v| v }
.reverse.map(&:first)
.compact_blank
.map { |k|
if filter == :editor
ApplicationController.helpers.display_editor_name(k)
elsif filter == :operating_system
ApplicationController.helpers.display_os_name(k)
elsif filter == :language
ApplicationController.helpers.display_language_name(k)
else
k
end
}
.uniq
if params[filter].present?
filter_arr = params[filter].split(",")
if %i[operating_system editor].include?(filter)
# search for both lowercase and capitalized versions
normalized_arr = filter_arr.flat_map { |v| [ v.downcase, v.capitalize ] }.uniq
filtered_heartbeats = filtered_heartbeats.where(filter => normalized_arr)
else
filtered_heartbeats = filtered_heartbeats.where(filter => filter_arr)
end
result["singular_#{filter}"] = filter_arr.length == 1
end
end
# Only use the concern for time filtering
filtered_heartbeats = filtered_heartbeats.filter_by_time_range(params[:interval], params[:from], params[:to])
result[:filtered_heartbeats] = filtered_heartbeats
# Calculate stats for filtered data
result[:total_time] = filtered_heartbeats.duration_seconds
result[:total_heartbeats] = filtered_heartbeats.count
filters.each do |filter|
result["top_#{filter}"] = filtered_heartbeats.group(filter)
.duration_seconds
.max_by { |_, v| v }
&.first
end
# Transform top editor, OS, and language names
result["top_editor"] = ApplicationController.helpers.display_editor_name(result["top_editor"]) if result["top_editor"]
result["top_operating_system"] = ApplicationController.helpers.display_os_name(result["top_operating_system"]) if result["top_operating_system"]
result["top_language"] = ApplicationController.helpers.display_language_name(result["top_language"]) if result["top_language"]
# Prepare project durations data
result[:project_durations] = filtered_heartbeats
.group(:project)
.duration_seconds
.sort_by { |_, duration| -duration }
.first(10)
.to_h unless result["singular_project"]
# Prepare pie chart data
%i[language editor operating_system category].each do |filter|
# If the filter is editor or operating_system, normalize and sum the durations
stats = filtered_heartbeats
.group(filter)
.duration_seconds
.each_with_object({}) do |(raw_key, duration), agg|
key = raw_key.to_s.presence || "Unknown"
key = key.downcase if %i[editor operating_system].include?(filter)
agg[key] = (agg[key] || 0) + duration
end
result["#{filter}_stats"] =
stats
.sort_by { |_, duration| -duration }
.first(10)
.map { |k, v|
label = case filter
when :editor then ApplicationController.helpers.display_editor_name(k)
when :operating_system then ApplicationController.helpers.display_os_name(k)
when :language then ApplicationController.helpers.display_language_name(k)
when :category then k
else k.capitalize
end
[ label, v ]
}
.to_h unless result["singular_#{filter}"]
end
# result[:language_stats] = filtered_heartbeats
# .group(:language)
# .duration_seconds
# .sort_by { |_, duration| -duration }
# .first(10)
# .map { |k, v| [ k.presence || "Unknown", v ] }
# .to_h unless result["singular_language"]
# result[:editor_stats] = filtered_heartbeats
# .group(:editor)
# .duration_seconds
# .sort_by { |_, duration| -duration }
# .map { |k, v| [ k.presence || "Unknown", v ] }
# .to_h unless result["singular_editor"]
# result[:operating_system_stats] = filtered_heartbeats
# .group(:operating_system)
# .duration_seconds
# .sort_by { |_, duration| -duration }
# .map { |k, v| [ k.presence || "Unknown", v ] }
# .to_h unless result["singular_operating_system"]
# result[:category_stats] = filtered_heartbeats
# .group(:category)
# .duration_seconds
# .sort_by { |_, duration| -duration }
# .map { |k, v| [ k.presence || "Unknown", v ] }
# .to_h unless result["singular_category"]
# Calculate weekly project stats for the last 6 months
result[:weekly_project_stats] = {}
(0..25).each do |week_offset| # 26 weeks = 6 months
week_start = week_offset.weeks.ago.beginning_of_week
week_end = week_offset.weeks.ago.end_of_week
week_stats = filtered_heartbeats
.where(time: week_start.to_f..week_end.to_f)
.group(:project)
.duration_seconds
result[:weekly_project_stats][week_start.to_date.iso8601] = week_stats
end
end
result
end
end
end