* Progress bar should be primary colour

* Projects page shouldn't refresh on archive

* Fix GitHub syncing

* Add HCA lookup

* Fix .ad and .mdx

* Misc.

* Badges can use owner/repo

* Format + make Zeitwerk happy
This commit is contained in:
Mahad Kalam 2026-03-23 12:51:04 +00:00 committed by GitHub
parent 245c458f41
commit 523d7e6ffb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 182 additions and 64 deletions

View file

@ -247,6 +247,12 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
.pick(:language)
end
# Infer language from file extension when client sends blank or Unknown
if heartbeat[:language].blank? || heartbeat[:language] == "Unknown"
inferred = LanguageUtils.detect_from_extension(heartbeat[:entity])
heartbeat[:language] = inferred if inferred
end
# Track the last known language for subsequent heartbeats in this batch.
last_language = heartbeat[:language] if heartbeat[:language].present?

View file

@ -0,0 +1,88 @@
module Api
module V1
class BadgesController < ApplicationController
skip_before_action :verify_authenticity_token
# GET /api/v1/badge/:user_id/*project
#
# Generates a shields.io badge showing coding time for a project.
# Supports lookup by slack_uid, username, or internal id.
# Project can be a project name ("hackatime") or owner/repo ("hackclub/hackatime").
def show
user = find_user(params[:user_id])
return render json: { error: "User not found" }, status: :not_found unless user
unless user.allow_public_stats_lookup
return render json: { error: "User has disabled public stats" }, status: :forbidden
end
project_name = resolve_project_name(user, params[:project])
return render json: { error: "Project not found" }, status: :not_found unless project_name
seconds = user.heartbeats.where(project: project_name).duration_seconds
return head :bad_request if seconds <= 0
label = params[:label] || "hackatime"
color = params[:color] || "blue"
time_text = format_duration(seconds)
shields_url = "https://img.shields.io/badge/#{ERB::Util.url_encode(label)}-#{ERB::Util.url_encode(time_text)}-#{ERB::Util.url_encode(color)}"
# Pass through any extra shields.io params (style, logo, etc.)
extra = params.to_unsafe_h.except(:controller, :action, :user_id, :project, :label, :color, :aliases, :format)
extra.each { |k, v| shields_url += "&#{ERB::Util.url_encode(k)}=#{ERB::Util.url_encode(v)}" }
# Handle aliases (comma-separated project names to sum)
if params[:aliases].present?
alias_names = params[:aliases].split(",").map(&:strip) - [ project_name ]
alias_seconds = user.heartbeats.where(project: alias_names).duration_seconds
seconds += alias_seconds
# Recalculate with alias time included
time_text = format_duration(seconds)
shields_url = "https://img.shields.io/badge/#{ERB::Util.url_encode(label)}-#{ERB::Util.url_encode(time_text)}-#{ERB::Util.url_encode(color)}"
extra.each { |k, v| shields_url += "&#{ERB::Util.url_encode(k)}=#{ERB::Util.url_encode(v)}" }
end
redirect_to shields_url, allow_other_host: true, status: :temporary_redirect
end
private
def find_user(identifier)
return nil if identifier.blank?
User.find_by(slack_uid: identifier) ||
User.find_by(username: identifier) ||
(identifier.match?(/^\d+$/) && User.find_by(id: identifier))
end
# Resolve owner/repo format to a project name via ProjectRepoMapping
def resolve_project_name(user, project_param)
return nil if project_param.blank?
# Direct match by project name first
if user.heartbeats.where(project: project_param).exists?
return project_param
end
# Try owner/repo → project_name lookup via repository
if project_param.include?("/")
mapping = user.project_repo_mappings
.joins(:repository)
.where(repositories: { owner: project_param.split("/", 2).first, name: project_param.split("/", 2).last })
.first
return mapping.project_name if mapping
end
nil
end
def format_duration(seconds)
hours = seconds / 3600
minutes = (seconds % 3600) / 60
hours > 0 ? "#{hours}h #{minutes}m" : "#{minutes}m"
end
end
end
end

View file

@ -248,6 +248,9 @@ class Api::V1::StatsController < ApplicationController
user = User.find_by(slack_uid: id)
return user if user
user = User.find_by(hca_id: id)
return user if user
# email lookup, but you really should not be using this cuz like wtf
# if identifier.include?("@")
# email_record = EmailAddress.find_by(email: identifier)

View file

@ -105,7 +105,8 @@ class Settings::BaseController < InertiaController
def project_list
@project_list ||= @user.project_repo_mappings.includes(:repository).distinct.map do |mapping|
{ display_name: mapping.project_name, repo_path: mapping.repository&.full_path || mapping.project_name }
repo_path = mapping.repository&.full_path || mapping.project_name
{ display_name: mapping.project_name, repo_path: repo_path }
end
end
@ -146,7 +147,8 @@ class Settings::BaseController < InertiaController
end
def badges_props
work_time_stats_base_url = @user.slack_uid.present? ? "https://hackatime-badge.hackclub.com/#{@user.slack_uid}/" : nil
badge_user_id = @user.slack_uid.presence || @user.username.presence || @user.id.to_s
work_time_stats_base_url = "#{request.base_url}/api/v1/badge/#{badge_user_id}/"
work_time_stats_url = if work_time_stats_base_url.present? && project_list.first.present?
"#{work_time_stats_base_url}#{project_list.first[:repo_path]}"
end

View file

@ -7,10 +7,10 @@ const pages = import.meta.glob<ResolvedComponent>("../pages/**/*.svelte", {
});
createInertiaApp({
// Disable progress bar
//
// see https://inertia-rails.dev/guide/progress-indicators
// progress: false,
progress: {
color: 'var(--color-primary)',
},
resolve: (name) => {
const component = pages[`../pages/${name}.svelte`];

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import { Link, router } from "@inertiajs/svelte";
import { onMount } from "svelte";
import Button from "../../components/Button.svelte";
import Modal from "../../components/Modal.svelte";
@ -158,6 +158,21 @@
pendingStatusAction = null;
};
const confirmStatusChange = () => {
if (!pendingStatusAction) return;
router.patch(
pendingStatusAction.path,
{},
{
preserveScroll: true,
onSuccess: () => {
statusChangeModalOpen = false;
pendingStatusAction = null;
},
},
);
};
const cardActionClass =
"inline-flex h-9 w-9 items-center justify-center rounded-lg bg-surface-content/5 text-surface-content/70 transition-colors duration-200 hover:bg-surface-content/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60";
</script>
@ -567,17 +582,14 @@
>
Cancel
</Button>
<form method="post" action={pendingStatusAction.path} class="m-0">
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button
type="submit"
variant="primary"
class="h-10 w-full text-on-primary"
>
{pendingStatusAction.confirmLabel}
</Button>
</form>
<Button
type="button"
variant="primary"
class="h-10 w-full text-on-primary"
onclick={confirmStatusChange}
>
{pendingStatusAction.confirmLabel}
</Button>
</div>
{/if}
{/snippet}

View file

@ -3,6 +3,7 @@ class SyncRepoMetadataJob < ApplicationJob
retry_on HTTP::TimeoutError, HTTP::ConnectionError, wait: :exponentially_longer, attempts: 3
retry_on JSON::ParserError, wait: 10.seconds, attempts: 2
retry_on "RepoHost::RateLimitError", wait: 15.minutes, attempts: 3
def perform(repository_id)
repository = Repository.find_by(id: repository_id)

View file

@ -1,53 +1,32 @@
class SyncStaleRepoMetadataJob < ApplicationJob
queue_as :default
BATCH_DELAY = 5.seconds
def perform
Rails.logger.info "[SyncStaleRepoMetadataJob] Starting sync of stale repository metadata"
# Find all mappings where the repository has stale metadata or is missing metadata entirely
mappings_with_stale_repos = ProjectRepoMapping.includes(:repository, :user)
.joins(:repository)
.where("repositories.last_synced_at IS NULL OR repositories.last_synced_at < ?", 1.day.ago)
# Also find mappings where repository is nil (shouldn't happen, but just in case)
mappings_without_repos = ProjectRepoMapping.includes(:user)
.where(repository: nil)
all_stale_mappings = mappings_with_stale_repos.to_a + mappings_without_repos.to_a
Rails.logger.info "[SyncStaleRepoMetadataJob] Found #{all_stale_mappings.count} project mappings with stale or missing repository metadata"
# Group by repository to avoid duplicate API calls
repos_to_sync = {}
all_stale_mappings.each do |mapping|
if mapping.repository
repos_to_sync[mapping.repository.id] = mapping.repository
else
# Handle mappings without repository - recreate the repository
Rails.logger.warn "[SyncStaleRepoMetadataJob] Found mapping without repository: #{mapping.inspect}"
if mapping.repo_url.present?
begin
repo = Repository.find_or_create_by_url(mapping.repo_url)
mapping.update!(repository: repo)
repos_to_sync[repo.id] = repo
rescue => e
report_error(e, message: "[SyncStaleRepoMetadataJob] Failed to create repository for mapping #{mapping.id}")
end
end
end
# Fix orphaned mappings (nil repository) first
ProjectRepoMapping.where(repository: nil).where.not(repo_url: [ nil, "" ]).find_each do |mapping|
repo = Repository.find_or_create_by_url(mapping.repo_url)
mapping.update!(repository: repo)
rescue => e
report_error(e, message: "[SyncStaleRepoMetadataJob] Failed to create repository for mapping #{mapping.id}")
end
Rails.logger.info "[SyncStaleRepoMetadataJob] Enqueuing sync for #{repos_to_sync.count} unique repositories"
# Query stale repositories directly, avoiding the N+1 on mappings
stale_repos = Repository.where("last_synced_at IS NULL OR last_synced_at < ?", 1.day.ago)
.joins(:users)
.distinct
repos_to_sync.each_value do |repository|
# Only sync if the repository has at least one user (needed for API access)
next unless repository.users.exists?
count = stale_repos.count
Rails.logger.info "[SyncStaleRepoMetadataJob] Enqueuing sync for #{count} stale repositories"
Rails.logger.info "[SyncStaleRepoMetadataJob] Enqueuing sync for #{repository.url}"
SyncRepoMetadataJob.perform_later(repository.id)
# Stagger jobs to avoid thundering herd / rate limit exhaustion
stale_repos.find_each.with_index do |repository, index|
SyncRepoMetadataJob.set(wait: index * BATCH_DELAY).perform_later(repository.id)
end
Rails.logger.info "[SyncStaleRepoMetadataJob] Completed enqueuing sync jobs"
Rails.logger.info "[SyncStaleRepoMetadataJob] Completed enqueuing #{count} sync jobs"
end
end

View file

@ -35,6 +35,25 @@ module LanguageUtils
data.keys.find { |name| name.downcase == key }
end
# Builds a lookup from file extension → canonical language name.
def self.extension_map
@extension_map ||= begin
map = {}
data.each do |name, info|
(info["extensions"] || []).each { |ext| map[ext.downcase] = name }
end
map
end
end
# Detect language from a file entity's extension.
def self.detect_from_extension(entity)
return nil if entity.blank?
ext = File.extname(entity).downcase
return nil if ext.blank?
extension_map[ext]
end
# Canonical display name: "js" → "JavaScript", "cpp" → "C++"
def self.display_name(raw)
return "Unknown" if raw.blank?

View file

@ -1,4 +1,6 @@
module RepoHost
class RateLimitError < StandardError; end
class BaseService < ApplicationService
def initialize(user, repo_url)
@user = user
@ -56,7 +58,7 @@ module RepoHost
if response.headers["X-RateLimit-Remaining"]&.to_i == 0
reset_time = Time.at(response.headers["X-RateLimit-Reset"].to_i)
delay_seconds = [ (reset_time - Time.current).ceil, 5 ].max
Rails.logger.warn "[#{self.class.name}] Rate limit exceeded. Reset in #{delay_seconds}s"
raise RateLimitError, "Rate limit exceeded for #{owner}/#{repo}. Reset in #{delay_seconds}s"
end
nil
end

View file

@ -127,12 +127,12 @@ Rails.application.configure do
cron: "0 2 * * *",
class: "ProcessAccountDeletionsJob",
description: "nuke accounts after 30 days"
},
sync_stale_repo_metadata: {
cron: "0 4 * * *",
class: "SyncStaleRepoMetadataJob",
description: "Refreshes repository metadata (stars, commit counts, etc.) for repositories with stale data."
}
# sync_stale_repo_metadata: {
# cron: "0 4 * * *", # Daily at 4 AM
# class: "SyncStaleRepoMetadataJob",
# description: "Refreshes repository metadata (stars, commit counts, etc.) for repositories with stale data."
# }
# cleanup_old_leaderboards: {
# cron: "0 3 * * *", # daily at 3
# class: "CleanupOldLeaderboardsJob",

View file

@ -7,3 +7,7 @@ Lapse:
tm_scope: none
ace_mode: text
language_id: 999900001
AsciiDoc:
extensions:
- ".ad"

View file

@ -201,6 +201,8 @@ Rails.application.routes.draw do
get "leaderboard/weekly", to: "leaderboard#weekly"
get "stats", to: "stats#show"
get "badge/:user_id/*project", to: "badges#show"
get "users/:username/stats", to: "stats#user_stats"
get "users/:username/heartbeats/spans", to: "stats#user_spans"
get "users/:username/trust_factor", to: "stats#trust_factor"

View file

@ -104,7 +104,7 @@ class Api::Hackatime::V1::HackatimeControllerTest < ActionDispatch::IntegrationT
assert_equal "Python", heartbeats.last.language
end
test "single heartbeat with <<LAST_LANGUAGE>> and no prior heartbeats stores nil language" do
test "single heartbeat with <<LAST_LANGUAGE>> and no prior heartbeats infers language from extension" do
user = User.create!(timezone: "UTC")
api_key = user.api_keys.create!(name: "primary")
@ -128,7 +128,7 @@ class Api::Hackatime::V1::HackatimeControllerTest < ActionDispatch::IntegrationT
assert_response :accepted
heartbeat = Heartbeat.order(:id).last
assert_nil heartbeat.language
assert_equal "Ruby", heartbeat.language
end
test "bulk heartbeat normalizes permitted params" do