mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-20 00:35:22 +00:00
427 lines
16 KiB
Ruby
427 lines
16 KiB
Ruby
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
|
||
|
||
show_archived = params[:show_archived] == "true"
|
||
|
||
if show_archived
|
||
@project_repo_mappings = current_user.project_repo_mappings.archived.includes(:repository)
|
||
else
|
||
@project_repo_mappings = current_user.project_repo_mappings.active.includes(:repository)
|
||
end
|
||
archived_projects = current_user.project_repo_mappings.archived.pluck(:project_name)
|
||
|
||
cache_key = "user_#{current_user.id}_project_durations_#{params[:interval]}_v2"
|
||
cache_key += "_#{params[:from]}_#{params[:to]}" if params[:interval] == "custom"
|
||
cache_key += "_archived" if show_archived
|
||
|
||
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",
|
||
project_key: project,
|
||
repo_url: mapping&.repo_url,
|
||
repository: mapping&.repository,
|
||
has_mapping: mapping.present?,
|
||
duration: duration
|
||
}
|
||
end.filter { |p| p[:duration].positive? }.sort_by { |p| p[:duration] }.reverse
|
||
end
|
||
|
||
if show_archived
|
||
project_durations = project_durations.select { |p| archived_projects.include?(p[:project_key]) }
|
||
else
|
||
project_durations = project_durations.reject { |p| archived_projects.include?(p[:project_key]) }
|
||
end
|
||
|
||
render partial: "project_durations", locals: { project_durations: project_durations, show_archived: show_archived }
|
||
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,
|
||
user_tz: user_tz
|
||
}
|
||
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 = {}
|
||
archived_projects = current_user.project_repo_mappings.archived.pluck(:project_name)
|
||
# Load filter options
|
||
Time.use_zone(current_user.timezone) do
|
||
filters.each do |filter|
|
||
group_by_time = current_user.heartbeats.group(filter).duration_seconds
|
||
group_by_time = group_by_time.reject { |name, _| archived_projects.include?(name) } if filter == :project
|
||
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|
|
||
stats = filtered_heartbeats.group(filter).duration_seconds
|
||
stats = stats.reject { |name, _| archived_projects.include?(name) } if filter == :project
|
||
result["top_#{filter}"] = stats.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
|
||
.reject { |project, _| archived_projects.include?(project) }
|
||
.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
|
||
.reject { |project, _| archived_projects.include?(project) }
|
||
|
||
result[:weekly_project_stats][week_start.to_date.iso8601] = week_stats
|
||
end
|
||
end
|
||
|
||
result
|
||
end
|
||
end
|
||
end
|